mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-10-05 08:02:13 +00:00
feat(ui): restyle the dialogs, notices and home page after the v4 design
- New ui::dialog module holds the shared modal chrome, taken from the workspace switcher: a 12px card, a 48px title row with an esc keycap, 18px insets, a 40px hairline footer, 28px borderless field wells on the faint fill, 11.5px medium muted labels, and 18px keycaps. - Buttons: the primary is the inverted neutral fill, the Commit button's paint, instead of the accent. Secondary buttons are transparent with the surface's hover rung. Override on a changed host key stays the one red button. A disabled button sinks to the faint fill and drops its click handler. - SSH sheet: host and fingerprint lines sit in a mono detail well, and keyboard-interactive prompts become field labels. Banners match the sheet's width and card shape. - Worktree prompt: moves to the same card, with the path preview hung off the Name field. - Notice pill: severity moves from a tinted edge to a 6px leading dot. - Home: shortcut rows are 28px with a hover fill and keycap chords, and the remote strip's action uses the secondary button.
This commit is contained in:
@@ -105,6 +105,16 @@ Git 的新增/修改标记种子为绿 `#2F8A52` / `#4CC27A`、琥珀 `#B7791F`
|
||||
「机器 · 路径 · 时间」次级文字,右侧标签数 + 状态词。右栏为预览:28px 标题行,
|
||||
44px 标签页行(18px 品牌头像、分支与增删数全部次级、「当前」字样、工作中 5px 绿点
|
||||
与侧栏同步闪烁)。当前行只用中性 selected 阶,不用强调色。
|
||||
- **对话框**(SSH 认证/主机密钥、新建 worktree):`ui::dialog` 统一外壳,同切换器的 12px 圆角、
|
||||
浮层阴影与遮罩、距顶 112px;标题行 48px(正文字号 medium,右侧 `esc` 键帽,下接细线),
|
||||
内容区左右 18px、字段间距 14px;字段名 11.5/16rem medium 辅助色,输入框为无边框 28px
|
||||
淡底井(`muted`、圆角 7)。页脚 40px 细线分隔,按钮 28px / 圆角 6、右对齐、主操作在最右:
|
||||
主按钮为反色中性(同提交按钮),次按钮透明、hover 取所在表面的 hover 阶;不可用时实心按钮
|
||||
退回淡底 + 辅助色且不响应点击。主机名与指纹放在淡底信息井里用等宽字。唯一的红色按钮是
|
||||
「主机密钥已变更」的 Override,该卡片也是唯一带红色描边的对话框。
|
||||
- **浮动提示条**:边缘保持中性细线,严重程度只由行首 6px 状态点表达(警告琥珀、断开红)。
|
||||
- **首页**:快捷操作列表 340px 宽,行高 28px、圆角 7,静止无底、hover 取窗口 hover 阶;
|
||||
快捷键用 18px 淡底键帽。
|
||||
- **终端**:ANSI 是用户内容,不拿来充当 UI 错误/成功配色;应用设计调整不得反转状态含义。
|
||||
|
||||
## 验证要求
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
//! Chrome for the small modal cards the app draws itself — the SSH sheet, the
|
||||
//! worktree prompt — and the buttons and key hints the home page shares with
|
||||
//! them.
|
||||
//!
|
||||
//! The workspace switcher is the reference: the same 12px card, 48px header
|
||||
//! row with an `esc` cap, hairline-divided footer and faint keycaps. Each of
|
||||
//! these cards used to spell its own padding, title weight and button variants
|
||||
//! out a builder call at a time, and they had drifted — a 20px inset here, a
|
||||
//! semibold title there, the accent-filled primary everywhere. One place now,
|
||||
//! so two sheets on screen at once cannot disagree about what a dialog is.
|
||||
|
||||
use gpui::{
|
||||
App, ClickEvent, Div, ElementId, Hsla, SharedString, Stateful, Window, div, prelude::*, px,
|
||||
rems,
|
||||
};
|
||||
use gpui_component::input::Input;
|
||||
use gpui_component::{ActiveTheme as _, Sizable as _, h_flex, v_flex};
|
||||
|
||||
use crate::ui::presets::Surface;
|
||||
use crate::ui::right_panel::{HEADING, TAB_TEXT, TEXT};
|
||||
|
||||
/// The card's corner — the switcher's, a notch rounder than a menu: a dialog
|
||||
/// is the one thing on screen while it is up.
|
||||
pub(crate) const CARD_RADIUS: f32 = 12.;
|
||||
|
||||
/// The title row, the height of the switcher's search row, so a prompt that
|
||||
/// opens where the switcher just closed puts its first line in the same place.
|
||||
pub(crate) const HEADER_H: f32 = 48.;
|
||||
|
||||
/// The button row. A 28px button in it leaves 6px above and below, the same
|
||||
/// fit as the switcher's footer and its New workspace button.
|
||||
pub(crate) const FOOTER_H: f32 = 40.;
|
||||
|
||||
/// How far the title and the fields sit in from the card's edge — the
|
||||
/// switcher's search-row inset, so the text columns of the two line up.
|
||||
pub(crate) const INSET: f32 = 18.;
|
||||
|
||||
/// A text field: a borderless well on the faint fill, the sidebar search's
|
||||
/// shape. The field's own `.small()` padding (8px) is what sets the text in.
|
||||
pub(crate) const FIELD_H: f32 = 28.;
|
||||
|
||||
/// Space between a field's label and its well, and between one field and the
|
||||
/// next. Four-pixel grid: tight inside a pair, a step wider between pairs.
|
||||
const LABEL_GAP: f32 = 6.;
|
||||
const FIELD_GAP: f32 = 14.;
|
||||
|
||||
/// A button: the field's height, a pixel less rounded so a button beside a
|
||||
/// field reads as a different kind of thing.
|
||||
pub(crate) const BUTTON_H: f32 = 28.;
|
||||
const BUTTON_RADIUS: f32 = 6.;
|
||||
|
||||
/// A key named in a hint: an 18px cap on the faint fill.
|
||||
pub(crate) const KEYCAP: f32 = 18.;
|
||||
|
||||
/// The ladder a dialog's hovers are taken from. Cards sit on the popover
|
||||
/// surface; the home page passes the window's own.
|
||||
pub(crate) fn popover_rungs(cx: &App) -> Surface {
|
||||
cx.global::<crate::ui::presets::Surfaces>().popover
|
||||
}
|
||||
|
||||
/// The card itself, opaque and occluding. Callers add the header, body and
|
||||
/// footer as children, in that order.
|
||||
pub(crate) fn card(width: f32, cx: &App) -> Div {
|
||||
v_flex()
|
||||
.occlude()
|
||||
.w(px(width))
|
||||
.map(|panel| crate::ui::theme::floating_surface(panel, cx))
|
||||
.rounded(px(CARD_RADIUS))
|
||||
.overflow_hidden()
|
||||
}
|
||||
|
||||
/// The title row: the title at body size in medium, and the `esc` cap on the
|
||||
/// right that says how to back out. Every card here answers Escape.
|
||||
pub(crate) fn header(title: impl IntoElement, cx: &App) -> Div {
|
||||
h_flex()
|
||||
.flex_none()
|
||||
.items_center()
|
||||
.gap(px(10.))
|
||||
.h(px(HEADER_H))
|
||||
.pl(px(INSET))
|
||||
.pr(px(14.))
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_size(rems(TEXT))
|
||||
.font_weight(gpui::FontWeight::MEDIUM)
|
||||
.child(title),
|
||||
)
|
||||
.child(keycap("esc", cx))
|
||||
}
|
||||
|
||||
/// The card's content column.
|
||||
pub(crate) fn body() -> Div {
|
||||
v_flex()
|
||||
.gap(px(FIELD_GAP))
|
||||
.px(px(INSET))
|
||||
.pt(px(16.))
|
||||
.pb(px(18.))
|
||||
}
|
||||
|
||||
/// The button row: answers on the right, the action last, over a hairline.
|
||||
pub(crate) fn footer(cx: &App) -> Div {
|
||||
h_flex()
|
||||
.flex_none()
|
||||
.items_center()
|
||||
.justify_end()
|
||||
.gap(px(6.))
|
||||
.h(px(FOOTER_H))
|
||||
.px(px(6.))
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().border)
|
||||
}
|
||||
|
||||
/// A field's name: the compact section-heading step, medium, secondary ink.
|
||||
pub(crate) fn label(text: impl IntoElement, cx: &App) -> Div {
|
||||
div()
|
||||
.text_size(rems(HEADING))
|
||||
.font_weight(gpui::FontWeight::MEDIUM)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(text)
|
||||
}
|
||||
|
||||
/// A text field in its well. The input loses its own border and fill — the
|
||||
/// well is the shape — and keeps its padding, which is the text's inset.
|
||||
pub(crate) fn field(input: Input, cx: &App) -> Div {
|
||||
h_flex()
|
||||
.items_center()
|
||||
.h(px(FIELD_H))
|
||||
.rounded(crate::ui::rounding::ROW_RADIUS)
|
||||
.bg(cx.theme().muted)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(input.appearance(false).small()),
|
||||
)
|
||||
}
|
||||
|
||||
/// A label over its field.
|
||||
pub(crate) fn labelled(name: impl IntoElement, input: Input, cx: &App) -> Div {
|
||||
v_flex()
|
||||
.gap(px(LABEL_GAP))
|
||||
.child(label(name, cx))
|
||||
.child(field(input, cx))
|
||||
}
|
||||
|
||||
/// A block of read-only detail — a host and its key — set on the field's
|
||||
/// fill, so it reads as a value to check rather than a line of prose.
|
||||
pub(crate) fn well(cx: &App) -> Div {
|
||||
v_flex()
|
||||
.gap(px(4.))
|
||||
.px(px(10.))
|
||||
.py(px(8.))
|
||||
.rounded(crate::ui::rounding::ROW_RADIUS)
|
||||
.bg(cx.theme().muted)
|
||||
}
|
||||
|
||||
/// A key named in a hint: a small faint cap, never a button outline. The same
|
||||
/// cap the switcher's header and footer draw.
|
||||
pub(crate) fn keycap(label: impl Into<SharedString>, cx: &App) -> gpui::AnyElement {
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.min_w(px(KEYCAP))
|
||||
.h(px(KEYCAP))
|
||||
.px(px(4.))
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded(px(4.))
|
||||
.bg(theme.muted)
|
||||
.text_size(rems(11. / 16.))
|
||||
.text_color(theme.muted_foreground)
|
||||
.child(label.into())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// What a button is asking for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Tone {
|
||||
/// The one answer a card is for: an inverted neutral, the surface's ink as
|
||||
/// the fill — the Commit button's paint, not the accent. The accent is
|
||||
/// already spent on focus and on the rail; a dialog of blue buttons made
|
||||
/// every sheet look like a call to action.
|
||||
Primary,
|
||||
/// Everything else: no fill at rest, the surface's hover rung under the
|
||||
/// pointer.
|
||||
Secondary,
|
||||
/// The action a card warns about. Red, and only on the one site that has
|
||||
/// earned it — overriding a changed host key.
|
||||
Danger,
|
||||
}
|
||||
|
||||
/// A dialog button. The click handler is attached only while `enabled`: a
|
||||
/// disabled button has to be inert, not merely grey.
|
||||
///
|
||||
/// Disabled, a filled tone sinks to the faint fill with secondary ink — the
|
||||
/// same fall the Commit button takes — so it never advertises an action that
|
||||
/// cannot run, and a secondary one just loses its ink.
|
||||
pub(crate) fn button(
|
||||
id: impl Into<ElementId>,
|
||||
text: impl Into<SharedString>,
|
||||
tone: Tone,
|
||||
enabled: bool,
|
||||
rungs: Surface,
|
||||
cx: &App,
|
||||
on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Stateful<Div> {
|
||||
let theme = cx.theme();
|
||||
let surface: Hsla = gpui::rgb(rungs.base).into();
|
||||
let (fill, hover, ink): (Option<Hsla>, Option<Hsla>, Hsla) = match (tone, enabled) {
|
||||
(Tone::Secondary, false) => (None, None, theme.muted_foreground),
|
||||
(_, false) => (Some(theme.muted), None, theme.muted_foreground),
|
||||
(Tone::Primary, true) => {
|
||||
let fg = theme.foreground;
|
||||
(Some(fg), Some(fg.blend(surface.opacity(0.14))), surface)
|
||||
}
|
||||
(Tone::Danger, true) => {
|
||||
let red = theme.danger;
|
||||
(
|
||||
Some(red),
|
||||
Some(red.blend(surface.opacity(0.14))),
|
||||
theme.danger_foreground,
|
||||
)
|
||||
}
|
||||
(Tone::Secondary, true) => (None, Some(gpui::rgb(rungs.hover).into()), theme.foreground),
|
||||
};
|
||||
h_flex()
|
||||
.id(id)
|
||||
.flex_none()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.h(px(BUTTON_H))
|
||||
.px(px(12.))
|
||||
.rounded(px(BUTTON_RADIUS))
|
||||
.text_size(rems(TAB_TEXT))
|
||||
.when(tone != Tone::Secondary, |b| {
|
||||
b.font_weight(gpui::FontWeight::MEDIUM)
|
||||
})
|
||||
.text_color(ink)
|
||||
.when_some(fill, |b, fill| b.bg(fill))
|
||||
.when_some(hover, |b, hover| b.hover(move |s| s.bg(hover)))
|
||||
.when(enabled, |b| b.cursor_pointer().on_click(on_click))
|
||||
.child(text.into())
|
||||
}
|
||||
+46
-27
@@ -4,9 +4,8 @@ use gpui::{
|
||||
Animation, AnimationExt as _, App, Context, KeyDownEvent, Keystroke, MouseButton, div,
|
||||
prelude::*, px,
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonVariants as _};
|
||||
use gpui_component::kbd::Kbd;
|
||||
use gpui_component::{ActiveTheme as _, IconName, Sizable as _, h_flex, v_flex};
|
||||
use gpui_component::{ActiveTheme as _, IconName, h_flex, v_flex};
|
||||
|
||||
use crate::core::actions::{NewTab, OpenSettings, ReopenClosedTab, TogglePalette, ToggleSwitcher};
|
||||
use crate::core::session::{SessionPane, SessionTab};
|
||||
@@ -35,6 +34,16 @@ const HOME_SHORTCUTS: [&str; 5] = [
|
||||
"OpenSettings",
|
||||
];
|
||||
|
||||
/// The shortcut list under the logo. Rows are the sidebar's 28px step rather
|
||||
/// than the old 42px buttons: five of them stacked at that height read as a
|
||||
/// wall of controls on a page whose only job is to say "press ⌘T".
|
||||
const HOME_LIST_W: f32 = 340.;
|
||||
const HOME_ROW_H: f32 = 28.;
|
||||
|
||||
/// A row's text inset: its hover fill bleeds this far past the label and the
|
||||
/// cap on either side.
|
||||
const HOME_ROW_PAD: f32 = 10.;
|
||||
|
||||
const CLOSED_LABEL_MAX: usize = 20;
|
||||
|
||||
fn closed_tab_label(tab: &SessionTab) -> Option<String> {
|
||||
@@ -204,7 +213,14 @@ impl Tty7App {
|
||||
|
||||
let closed_hint = self.closed.last().and_then(closed_tab_label);
|
||||
let nothing_to_reopen = self.closed.is_empty();
|
||||
let mut list = v_flex().gap_2().w(px(340.)).text_sm().text_color(muted);
|
||||
// Rows on the sidebar's shape: no fill at rest, the window's hover
|
||||
// rung under the pointer, the chord in a faint cap at the far end.
|
||||
let hover = gpui::rgb(cx.global::<crate::ui::presets::Surfaces>().window.hover);
|
||||
let mut list = v_flex()
|
||||
.gap(px(2.))
|
||||
.w(px(HOME_LIST_W))
|
||||
.text_size(gpui::rems(crate::ui::right_panel::TEXT))
|
||||
.text_color(muted);
|
||||
for action in HOME_SHORTCUTS {
|
||||
if action == "ReopenClosedTab" && nothing_to_reopen {
|
||||
continue;
|
||||
@@ -212,22 +228,24 @@ impl Tty7App {
|
||||
let emphasized = closed_hint.is_some() && action == "ReopenClosedTab";
|
||||
let label = home_shortcut_label(action, closed_hint.as_deref());
|
||||
list = list.child(
|
||||
Button::new(action)
|
||||
.ghost()
|
||||
h_flex()
|
||||
.id(action)
|
||||
.w_full()
|
||||
.min_h(gpui::rems(2.625))
|
||||
.rounded(crate::ui::rounding::CARD_RADIUS)
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.text_color(muted)
|
||||
.when(emphasized || action == "NewTab", |row| {
|
||||
row.text_color(foreground)
|
||||
})
|
||||
.child(label)
|
||||
.children(key_stroke(action, cx).map(Kbd::new)),
|
||||
.h(px(HOME_ROW_H))
|
||||
.px(px(HOME_ROW_PAD))
|
||||
.gap(px(12.))
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.rounded(crate::ui::rounding::ROW_RADIUS)
|
||||
.cursor_pointer()
|
||||
.hover(move |row| row.bg(hover))
|
||||
.when(emphasized || action == "NewTab", |row| {
|
||||
row.text_color(foreground)
|
||||
})
|
||||
.child(div().min_w_0().truncate().child(label))
|
||||
.children(
|
||||
key_stroke(action, cx)
|
||||
.map(|stroke| crate::ui::dialog::keycap(Kbd::format(&stroke), cx)),
|
||||
)
|
||||
.on_click(move |_, window, cx| {
|
||||
let command: Box<dyn gpui::Action> = match action {
|
||||
@@ -310,17 +328,18 @@ impl Tty7App {
|
||||
.child(crate::ui::remote_workspace::status_message(message))
|
||||
.when_some(action, |this, (label, action)| {
|
||||
this.child(
|
||||
Button::new("home-remote-status-action")
|
||||
.flex_shrink_0()
|
||||
.label(label)
|
||||
.ghost()
|
||||
.small()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
crate::ui::dialog::button(
|
||||
"home-remote-status-action",
|
||||
label,
|
||||
crate::ui::dialog::Tone::Secondary,
|
||||
true,
|
||||
crate::ui::dialog::popover_rungs(cx),
|
||||
cx,
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
this.run_strip_action(action.clone(), window, cx);
|
||||
}))
|
||||
.on_mouse_down(MouseButton::Left, |_, _, cx| {
|
||||
cx.stop_propagation()
|
||||
}),
|
||||
)
|
||||
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod app;
|
||||
pub mod assets;
|
||||
pub mod code_editor;
|
||||
pub mod dialog;
|
||||
pub mod diff_list;
|
||||
pub mod diff_overlay;
|
||||
pub mod diff_rows;
|
||||
|
||||
+22
-7
@@ -14,29 +14,44 @@
|
||||
//! anchor is a column: a second notice stacks above the first instead of
|
||||
//! landing on it.
|
||||
|
||||
use gpui::{AnyElement, App, Div, Hsla, div, prelude::*};
|
||||
use gpui::{AnyElement, App, Div, Hsla, div, prelude::*, px};
|
||||
use gpui_component::{ActiveTheme as _, h_flex, v_flex};
|
||||
|
||||
/// Chrome for one floating notice. `accent` is the border, and is the only
|
||||
/// thing that says how bad this one is; the rest of the pill is the same
|
||||
/// Chrome for one floating notice. `accent` is the leading dot, and is the
|
||||
/// only thing that says how bad this one is; the rest of the pill is the same
|
||||
/// whatever went wrong.
|
||||
///
|
||||
/// The severity used to tint the pill's whole edge. A coloured outline is
|
||||
/// chrome wearing a status, and at 40% it was too faint to read as one — a
|
||||
/// dot is the shape the rail and the switcher already use for "this state,
|
||||
/// in this colour", and it leaves the edge the same hairline every other
|
||||
/// floating surface has.
|
||||
pub(crate) fn pill(accent: Hsla, cx: &App) -> Div {
|
||||
let theme = cx.theme();
|
||||
h_flex()
|
||||
.occlude()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_4()
|
||||
.py_2()
|
||||
.gap(px(8.))
|
||||
.min_h(px(PILL_H))
|
||||
.pl(px(12.))
|
||||
.pr(px(14.))
|
||||
.py(px(6.))
|
||||
.map(|panel| crate::ui::theme::floating_surface(panel, cx))
|
||||
.border_color(accent.opacity(0.4))
|
||||
// Off the right panel's ramp on purpose: these float over the
|
||||
// terminal, not inside a panel, and are sized against the terminal's
|
||||
// own text.
|
||||
.text_xs()
|
||||
.text_color(theme.muted_foreground)
|
||||
.child(div().flex_none().size(px(DOT)).rounded_full().bg(accent))
|
||||
}
|
||||
|
||||
/// A one-line pill's floor. A longer reason still wraps it taller; this only
|
||||
/// stops a short one from shrinking to a sliver around 12px text.
|
||||
const PILL_H: f32 = 32.;
|
||||
|
||||
/// The severity dot, the rail's status-dot size.
|
||||
const DOT: f32 = 6.;
|
||||
|
||||
/// Anchors whatever notices are up as one bottom-centred column, so two of
|
||||
/// them stack rather than collide. `None` when there is nothing to show, which
|
||||
/// is what lets the caller keep using `when_some`.
|
||||
|
||||
+243
-236
@@ -1,17 +1,23 @@
|
||||
use gpui::{
|
||||
AnyElement, Context, Entity, FocusHandle, IntoElement, ParentElement as _, Styled as _,
|
||||
Subscription, Window, div, prelude::*, px,
|
||||
AnyElement, App, Context, Entity, FocusHandle, IntoElement, ParentElement as _, Styled as _,
|
||||
Subscription, Window, div, prelude::*, px, rems,
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonVariants as _};
|
||||
use gpui_component::checkbox::Checkbox;
|
||||
use gpui_component::input::{Input, InputEvent, InputState};
|
||||
use gpui_component::{ActiveTheme as _, Disableable as _, Sizable as _, h_flex, v_flex};
|
||||
use gpui_component::{ActiveTheme as _, Sizable as _, h_flex, v_flex};
|
||||
|
||||
use crate::core::keychain::{CredentialStore as _, OsCredentialStore};
|
||||
use crate::daemon::protocol::{AuthPromptKind, AuthResponse, SshPhase};
|
||||
use crate::terminal::view::TerminalView;
|
||||
|
||||
use super::app::Tty7App;
|
||||
use super::dialog::{self, Tone};
|
||||
use super::i18n::{L10nKey, t, t_fmt};
|
||||
use super::right_panel::{META, META_MONO, TEXT};
|
||||
|
||||
/// The sheet's width, and the width of the banners stacked above it, so the
|
||||
/// column reads as one.
|
||||
const SHEET_W: f32 = 440.;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct KiRow {
|
||||
@@ -690,7 +696,7 @@ impl Tty7App {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut stack = v_flex().gap_2().items_center();
|
||||
let mut stack = v_flex().gap(px(8.)).items_center();
|
||||
|
||||
for (ix, banner) in self.ssh_prompt.banners.iter().enumerate() {
|
||||
stack = stack.child(self.render_ssh_banner(ix, banner, cx));
|
||||
@@ -726,120 +732,129 @@ impl Tty7App {
|
||||
fn render_ssh_banner(&self, ix: usize, text: &str, cx: &mut Context<Self>) -> AnyElement {
|
||||
h_flex()
|
||||
.occlude()
|
||||
.w(px(460.))
|
||||
.gap_2()
|
||||
.p_2()
|
||||
.w(px(SHEET_W))
|
||||
.items_center()
|
||||
.gap(px(10.))
|
||||
.min_h(px(dialog::HEADER_H))
|
||||
.pl(px(dialog::INSET))
|
||||
.pr(px(10.))
|
||||
.py(px(6.))
|
||||
.map(|panel| crate::ui::theme::floating_surface(panel, cx))
|
||||
.child(div().flex_1().text_sm().child(text.to_string()))
|
||||
.rounded(px(dialog::CARD_RADIUS))
|
||||
.child(
|
||||
Button::new(("ssh-banner-dismiss", ix))
|
||||
.label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::Dismiss))
|
||||
.small()
|
||||
.ghost()
|
||||
.on_click(cx.listener(move |this, _, _w, cx| this.dismiss_ssh_banner(ix, cx))),
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_size(rems(TEXT))
|
||||
.child(text.to_string()),
|
||||
)
|
||||
.child(dialog::button(
|
||||
("ssh-banner-dismiss", ix),
|
||||
t(L10nKey::Dismiss),
|
||||
Tone::Secondary,
|
||||
true,
|
||||
dialog::popover_rungs(cx),
|
||||
cx,
|
||||
cx.listener(move |this, _, _w, cx| this.dismiss_ssh_banner(ix, cx)),
|
||||
))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_ssh_sheet(&self, model: &PromptModel, cx: &mut Context<Self>) -> AnyElement {
|
||||
let danger = cx.theme().danger;
|
||||
let theme = cx.theme();
|
||||
let (danger, muted, fg) = (theme.danger, theme.muted_foreground, theme.foreground);
|
||||
let rungs = dialog::popover_rungs(cx);
|
||||
let (title, danger_sheet) = match model {
|
||||
PromptModel::Password { user, host, .. } => (
|
||||
crate::ui::i18n::t_fmt(
|
||||
crate::ui::i18n::L10nKey::SshPromptPasswordFor,
|
||||
t_fmt(
|
||||
L10nKey::SshPromptPasswordFor,
|
||||
&[("user", user), ("host", host)],
|
||||
),
|
||||
false,
|
||||
),
|
||||
PromptModel::KeyPassphrase { key_path, .. } => (
|
||||
crate::ui::i18n::t_fmt(
|
||||
crate::ui::i18n::L10nKey::SshPromptPassphraseFor,
|
||||
&[("key_path", key_path)],
|
||||
),
|
||||
t_fmt(L10nKey::SshPromptPassphraseFor, &[("key_path", key_path)]),
|
||||
false,
|
||||
),
|
||||
PromptModel::KeyboardInteractive { name, .. } => {
|
||||
let label = if name.is_empty() {
|
||||
crate::ui::i18n::t(crate::ui::i18n::L10nKey::SshPromptTwoFactor).to_string()
|
||||
t(L10nKey::SshPromptTwoFactor).to_string()
|
||||
} else {
|
||||
name.clone()
|
||||
};
|
||||
(label, false)
|
||||
}
|
||||
PromptModel::HostKeyUnknown { host, .. } => (
|
||||
crate::ui::i18n::t_fmt(
|
||||
crate::ui::i18n::L10nKey::SshPromptUnknownHost,
|
||||
&[("host", host)],
|
||||
),
|
||||
t_fmt(L10nKey::SshPromptUnknownHost, &[("host", host)]),
|
||||
false,
|
||||
),
|
||||
PromptModel::HostKeyChanged { .. } => (
|
||||
crate::ui::i18n::t(crate::ui::i18n::L10nKey::SshPromptHostKeyChanged).to_string(),
|
||||
true,
|
||||
),
|
||||
PromptModel::HostKeyChanged { .. } => {
|
||||
(t(L10nKey::SshPromptHostKeyChanged).to_string(), true)
|
||||
}
|
||||
};
|
||||
|
||||
let mut card = v_flex()
|
||||
.occlude()
|
||||
// One line of the sheet's prose: caption size, in the ink it is told.
|
||||
let line =
|
||||
|text: String, ink: gpui::Hsla| div().text_size(rems(META)).text_color(ink).child(text);
|
||||
// A host line or a fingerprint, set in mono inside the detail well.
|
||||
let mono = |text: String, ink: gpui::Hsla| {
|
||||
div()
|
||||
.text_size(rems(META_MONO))
|
||||
.font_family("monospace")
|
||||
.text_color(ink)
|
||||
.child(text)
|
||||
};
|
||||
|
||||
let card = dialog::card(SHEET_W, cx)
|
||||
.track_focus(&self.ssh_prompt.focus_handle)
|
||||
.key_context("SshPrompt")
|
||||
.w(px(440.))
|
||||
.gap_4()
|
||||
.p_5()
|
||||
.map(|panel| crate::ui::theme::floating_surface(panel, cx))
|
||||
// The changed-key sheet is the one card allowed a coloured edge:
|
||||
// it is what a man-in-the-middle looks like.
|
||||
.when(danger_sheet, |panel| panel.border_color(danger))
|
||||
.on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, window, cx| {
|
||||
if ev.keystroke.key == "escape" {
|
||||
this.cancel_ssh_prompt(window, cx);
|
||||
}
|
||||
}));
|
||||
}))
|
||||
.child(dialog::header(
|
||||
div()
|
||||
.when(danger_sheet, |d| d.text_color(danger))
|
||||
.child(title),
|
||||
cx,
|
||||
));
|
||||
|
||||
card = card.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||||
.when(danger_sheet, |d| d.text_color(danger))
|
||||
.child(title),
|
||||
);
|
||||
|
||||
card = match model {
|
||||
let (body, footer) = match model {
|
||||
PromptModel::Password { rejected, .. } => {
|
||||
let mut c = card;
|
||||
if *rejected {
|
||||
c = c.child(div().text_xs().text_color(danger).child(crate::ui::i18n::t(
|
||||
crate::ui::i18n::L10nKey::StoredPasswordRejected,
|
||||
)));
|
||||
}
|
||||
c.child(self.render_ssh_input(0))
|
||||
.child(self.render_ssh_remember(cx))
|
||||
.child(self.render_ssh_actions(
|
||||
crate::ui::i18n::t(crate::ui::i18n::L10nKey::SshPromptConnect),
|
||||
cx,
|
||||
))
|
||||
let body = dialog::body()
|
||||
.when(*rejected, |c| {
|
||||
c.child(line(t(L10nKey::StoredPasswordRejected).to_string(), danger))
|
||||
})
|
||||
.child(self.render_ssh_input(0, cx))
|
||||
.child(self.render_ssh_remember(cx));
|
||||
(
|
||||
body,
|
||||
self.render_ssh_actions(t(L10nKey::SshPromptConnect), cx),
|
||||
)
|
||||
}
|
||||
PromptModel::KeyPassphrase {
|
||||
comment, rejected, ..
|
||||
} => {
|
||||
let mut c = card;
|
||||
if *rejected {
|
||||
c = c.child(div().text_xs().text_color(danger).child(crate::ui::i18n::t(
|
||||
crate::ui::i18n::L10nKey::StoredPassphraseRejected,
|
||||
)));
|
||||
}
|
||||
if !comment.is_empty() {
|
||||
c = c.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(comment.clone()),
|
||||
);
|
||||
}
|
||||
c.child(self.render_ssh_input(0))
|
||||
.child(self.render_ssh_remember(cx))
|
||||
.child(self.render_ssh_actions(
|
||||
crate::ui::i18n::t(crate::ui::i18n::L10nKey::SshPromptUnlock),
|
||||
cx,
|
||||
))
|
||||
let body = dialog::body()
|
||||
.when(*rejected, |c| {
|
||||
c.child(line(
|
||||
t(L10nKey::StoredPassphraseRejected).to_string(),
|
||||
danger,
|
||||
))
|
||||
})
|
||||
.when(!comment.is_empty(), |c| {
|
||||
c.child(line(comment.clone(), muted))
|
||||
})
|
||||
.child(self.render_ssh_input(0, cx))
|
||||
.child(self.render_ssh_remember(cx));
|
||||
(
|
||||
body,
|
||||
self.render_ssh_actions(t(L10nKey::SshPromptUnlock), cx),
|
||||
)
|
||||
}
|
||||
PromptModel::KeyboardInteractive {
|
||||
instructions,
|
||||
@@ -847,23 +862,26 @@ impl Tty7App {
|
||||
stored_rejected,
|
||||
..
|
||||
} => {
|
||||
let mut c = card;
|
||||
if *stored_rejected {
|
||||
c = c.child(div().text_xs().text_color(danger).child(crate::ui::i18n::t(
|
||||
crate::ui::i18n::L10nKey::StoredPasswordRejected,
|
||||
)));
|
||||
}
|
||||
if !instructions.is_empty() {
|
||||
c = c.child(div().text_xs().child(instructions.clone()));
|
||||
}
|
||||
let mut body = dialog::body()
|
||||
.when(*stored_rejected, |c| {
|
||||
c.child(line(t(L10nKey::StoredPasswordRejected).to_string(), danger))
|
||||
})
|
||||
.when(!instructions.is_empty(), |c| {
|
||||
c.child(line(instructions.clone(), fg))
|
||||
});
|
||||
// Each prompt is the name of the field under it.
|
||||
for (i, row) in prompts.iter().enumerate() {
|
||||
c = c.child(div().text_xs().child(row.text.clone()));
|
||||
c = c.child(self.render_ssh_input(i));
|
||||
body = body.child(
|
||||
v_flex()
|
||||
.gap(px(6.))
|
||||
.child(dialog::label(row.text.clone(), cx))
|
||||
.child(self.render_ssh_input(i, cx)),
|
||||
);
|
||||
}
|
||||
c.child(self.render_ssh_actions(
|
||||
crate::ui::i18n::t(crate::ui::i18n::L10nKey::SshPromptSubmit),
|
||||
cx,
|
||||
))
|
||||
(
|
||||
body,
|
||||
self.render_ssh_actions(t(L10nKey::SshPromptSubmit), cx),
|
||||
)
|
||||
}
|
||||
PromptModel::HostKeyUnknown {
|
||||
algorithm,
|
||||
@@ -871,51 +889,49 @@ impl Tty7App {
|
||||
port,
|
||||
host,
|
||||
previously_known_as,
|
||||
} => card
|
||||
.child(div().text_xs().child(format!("{host}:{port} {algorithm}")))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_family("monospace")
|
||||
.child(fingerprint.clone()),
|
||||
)
|
||||
// A host that already has an entry under another algorithm is
|
||||
// the ordinary way a server grows an ed25519 key beside its old
|
||||
// ssh-rsa one. Saying so is the difference between "who is
|
||||
// this?" and "this is the host you know, with a second key".
|
||||
.when_some(previously_known_as.as_ref(), |c, previous| {
|
||||
c.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(crate::ui::i18n::t_fmt(
|
||||
crate::ui::i18n::L10nKey::SshPromptHostKeyNewAlgorithm,
|
||||
&[("previous_algorithm", previous), ("algorithm", algorithm)],
|
||||
)),
|
||||
} => {
|
||||
let body = dialog::body()
|
||||
.child(
|
||||
dialog::well(cx)
|
||||
.child(mono(format!("{host}:{port} {algorithm}"), muted))
|
||||
.child(mono(fingerprint.clone(), fg)),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_end()
|
||||
.gap_2()
|
||||
.child(
|
||||
Button::new("ssh-hk-abort")
|
||||
.label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::Abort))
|
||||
.small()
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.cancel_ssh_prompt(window, cx)
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("ssh-hk-trust")
|
||||
.label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::Trust))
|
||||
.small()
|
||||
.primary()
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.trust_ssh_host_key(window, cx)
|
||||
})),
|
||||
),
|
||||
),
|
||||
// A host that already has an entry under another algorithm
|
||||
// is the ordinary way a server grows an ed25519 key beside
|
||||
// its old ssh-rsa one. Saying so is the difference between
|
||||
// "who is this?" and "this is the host you know, with a
|
||||
// second key".
|
||||
.when_some(previously_known_as.as_ref(), |c, previous| {
|
||||
c.child(line(
|
||||
t_fmt(
|
||||
L10nKey::SshPromptHostKeyNewAlgorithm,
|
||||
&[("previous_algorithm", previous), ("algorithm", algorithm)],
|
||||
),
|
||||
muted,
|
||||
))
|
||||
});
|
||||
let footer = dialog::footer(cx)
|
||||
.child(dialog::button(
|
||||
"ssh-hk-abort",
|
||||
t(L10nKey::Abort),
|
||||
Tone::Secondary,
|
||||
true,
|
||||
rungs,
|
||||
cx,
|
||||
cx.listener(|this, _, window, cx| this.cancel_ssh_prompt(window, cx)),
|
||||
))
|
||||
.child(dialog::button(
|
||||
"ssh-hk-trust",
|
||||
t(L10nKey::Trust),
|
||||
Tone::Primary,
|
||||
true,
|
||||
rungs,
|
||||
cx,
|
||||
cx.listener(|this, _, window, cx| this.trust_ssh_host_key(window, cx)),
|
||||
))
|
||||
.into_any_element();
|
||||
(body, footer)
|
||||
}
|
||||
PromptModel::HostKeyChanged {
|
||||
algorithm,
|
||||
fingerprint,
|
||||
@@ -935,89 +951,82 @@ impl Tty7App {
|
||||
.map(|i| i.read(cx).value().to_string())
|
||||
.unwrap_or_default();
|
||||
let can_override = changed_confirmed(&typed);
|
||||
card.child(div().text_xs().text_color(danger).child(crate::ui::i18n::t(
|
||||
crate::ui::i18n::L10nKey::SshPromptHostKeyChangedBody,
|
||||
)))
|
||||
.child(div().text_xs().child(format!("{host}:{port} {algorithm}")))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_family("monospace")
|
||||
.child(crate::ui::i18n::t_fmt(
|
||||
crate::ui::i18n::L10nKey::SshPromptNewKey,
|
||||
&[("fingerprint", &fingerprint)],
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_family("monospace")
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(crate::ui::i18n::t_fmt(
|
||||
crate::ui::i18n::L10nKey::SshPromptOldKey,
|
||||
&[("old_fingerprint", &old_fingerprint)],
|
||||
)),
|
||||
)
|
||||
.child(div().text_xs().child(crate::ui::i18n::t(
|
||||
crate::ui::i18n::L10nKey::HostKeyOverrideMessage,
|
||||
)))
|
||||
.child(self.render_ssh_input(0))
|
||||
// Only once they have typed something: an empty field is not a
|
||||
// mistake to be corrected, it is where everyone starts.
|
||||
.when(!typed.trim().is_empty() && !can_override, |c| {
|
||||
c.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(crate::ui::i18n::t(
|
||||
crate::ui::i18n::L10nKey::SshPromptTypeYesToOverride,
|
||||
let body = dialog::body()
|
||||
.child(line(
|
||||
t(L10nKey::SshPromptHostKeyChangedBody).to_string(),
|
||||
danger,
|
||||
))
|
||||
.child(
|
||||
dialog::well(cx)
|
||||
.child(mono(format!("{host}:{port} {algorithm}"), muted))
|
||||
.child(mono(
|
||||
t_fmt(L10nKey::SshPromptNewKey, &[("fingerprint", &fingerprint)]),
|
||||
fg,
|
||||
))
|
||||
.child(mono(
|
||||
t_fmt(
|
||||
L10nKey::SshPromptOldKey,
|
||||
&[("old_fingerprint", &old_fingerprint)],
|
||||
),
|
||||
muted,
|
||||
)),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_end()
|
||||
.gap_2()
|
||||
// Abort stays the emphasized one and now also sits
|
||||
// where the eye lands last: a changed host key is the
|
||||
// one prompt where the safe answer wants both.
|
||||
//
|
||||
// Override is the app's one `danger` button, and it is
|
||||
// the site that earns it: the sheet is what a
|
||||
// man-in-the-middle looks like, and this was the only
|
||||
// control in the product that could act on that with
|
||||
// no colour on it at all. It is disabled until the word
|
||||
// is typed, so it greys until armed and then goes red —
|
||||
// the emphasis arrives exactly when the button does.
|
||||
.child(
|
||||
Button::new("ssh-hkc-override")
|
||||
.label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::Override))
|
||||
.small()
|
||||
.danger()
|
||||
.disabled(!can_override)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.submit_ssh_prompt(window, cx)
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("ssh-hkc-abort")
|
||||
.label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::Abort))
|
||||
.small()
|
||||
.primary()
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.cancel_ssh_prompt(window, cx)
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap(px(6.))
|
||||
.child(line(t(L10nKey::HostKeyOverrideMessage).to_string(), fg))
|
||||
.child(self.render_ssh_input(0, cx))
|
||||
// Only once they have typed something: an empty
|
||||
// field is not a mistake to be corrected, it is
|
||||
// where everyone starts.
|
||||
.when(!typed.trim().is_empty() && !can_override, |c| {
|
||||
c.child(line(
|
||||
t(L10nKey::SshPromptTypeYesToOverride).to_string(),
|
||||
muted,
|
||||
))
|
||||
}),
|
||||
);
|
||||
// Abort stays the emphasized one and also sits where the eye
|
||||
// lands last: a changed host key is the one prompt where the
|
||||
// safe answer wants both.
|
||||
//
|
||||
// Override is the app's one `danger` button, and it is the
|
||||
// site that earns it: the sheet is what a man-in-the-middle
|
||||
// looks like, and this was the only control in the product
|
||||
// that could act on that with no colour on it at all. It is
|
||||
// inert until the word is typed, so it stays faint until armed
|
||||
// and then goes red — the emphasis arrives exactly when the
|
||||
// button does.
|
||||
let footer = dialog::footer(cx)
|
||||
.child(dialog::button(
|
||||
"ssh-hkc-override",
|
||||
t(L10nKey::Override),
|
||||
Tone::Danger,
|
||||
can_override,
|
||||
rungs,
|
||||
cx,
|
||||
cx.listener(|this, _, window, cx| this.submit_ssh_prompt(window, cx)),
|
||||
))
|
||||
.child(dialog::button(
|
||||
"ssh-hkc-abort",
|
||||
t(L10nKey::Abort),
|
||||
Tone::Primary,
|
||||
true,
|
||||
rungs,
|
||||
cx,
|
||||
cx.listener(|this, _, window, cx| this.cancel_ssh_prompt(window, cx)),
|
||||
))
|
||||
.into_any_element();
|
||||
(body, footer)
|
||||
}
|
||||
};
|
||||
|
||||
card.into_any_element()
|
||||
card.child(body).child(footer).into_any_element()
|
||||
}
|
||||
|
||||
fn render_ssh_input(&self, ix: usize) -> AnyElement {
|
||||
fn render_ssh_input(&self, ix: usize, cx: &App) -> AnyElement {
|
||||
match self.ssh_prompt.inputs.get(ix) {
|
||||
Some(state) => Input::new(state).small().into_any_element(),
|
||||
Some(state) => dialog::field(Input::new(state), cx).into_any_element(),
|
||||
None => div().into_any_element(),
|
||||
}
|
||||
}
|
||||
@@ -1026,9 +1035,8 @@ impl Tty7App {
|
||||
h_flex()
|
||||
.child(
|
||||
Checkbox::new("ssh-remember")
|
||||
.label(crate::ui::i18n::t(
|
||||
crate::ui::i18n::L10nKey::RememberKeychain,
|
||||
))
|
||||
.label(t(L10nKey::RememberKeychain))
|
||||
.small()
|
||||
.checked(self.ssh_prompt.remember)
|
||||
.on_click(cx.listener(|this, _, _w, cx| this.toggle_ssh_remember(cx))),
|
||||
)
|
||||
@@ -1040,27 +1048,26 @@ impl Tty7App {
|
||||
/// `ui::confirm_answers` settled that arrangement for every native alert
|
||||
/// the app raises; a sheet the app draws itself has no reason to mirror it.
|
||||
fn render_ssh_actions(&self, submit_label: &str, cx: &mut Context<Self>) -> AnyElement {
|
||||
let submit_label = submit_label.to_string();
|
||||
h_flex()
|
||||
.justify_end()
|
||||
.gap_2()
|
||||
.child(
|
||||
Button::new("ssh-cancel")
|
||||
.label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::Cancel))
|
||||
.small()
|
||||
.on_click(
|
||||
cx.listener(|this, _, window, cx| this.cancel_ssh_prompt(window, cx)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new("ssh-submit")
|
||||
.label(submit_label)
|
||||
.small()
|
||||
.primary()
|
||||
.on_click(
|
||||
cx.listener(|this, _, window, cx| this.submit_ssh_prompt(window, cx)),
|
||||
),
|
||||
)
|
||||
let rungs = dialog::popover_rungs(cx);
|
||||
dialog::footer(cx)
|
||||
.child(dialog::button(
|
||||
"ssh-cancel",
|
||||
t(L10nKey::Cancel),
|
||||
Tone::Secondary,
|
||||
true,
|
||||
rungs,
|
||||
cx,
|
||||
cx.listener(|this, _, window, cx| this.cancel_ssh_prompt(window, cx)),
|
||||
))
|
||||
.child(dialog::button(
|
||||
"ssh-submit",
|
||||
submit_label.to_string(),
|
||||
Tone::Primary,
|
||||
true,
|
||||
rungs,
|
||||
cx,
|
||||
cx.listener(|this, _, window, cx| this.submit_ssh_prompt(window, cx)),
|
||||
))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
+54
-56
@@ -1,13 +1,12 @@
|
||||
use gpui::{AnyElement, Context, Entity, Subscription, Window, div, prelude::*, px};
|
||||
use gpui_component::button::{Button, ButtonVariants as _};
|
||||
use gpui::{AnyElement, Context, Entity, Subscription, Window, div, prelude::*, px, rems};
|
||||
use gpui_component::input::{Input, InputEvent, InputState};
|
||||
use gpui_component::{
|
||||
ActiveTheme as _, Disableable as _, Sizable as _, WindowExt as _, h_flex, v_flex,
|
||||
};
|
||||
use gpui_component::{ActiveTheme as _, WindowExt as _};
|
||||
|
||||
use crate::core::worktree::{WorktreeDefaults, WorktreeRequest};
|
||||
use crate::ui::app::Tty7App;
|
||||
use crate::ui::dialog::{self, Tone};
|
||||
use crate::ui::i18n::{L10nKey, t, t_fmt};
|
||||
use crate::ui::right_panel::META_MONO;
|
||||
|
||||
pub(crate) struct WorktreePrompt {
|
||||
host: crate::ui::host_ops::SharedHost,
|
||||
@@ -135,12 +134,6 @@ impl Tty7App {
|
||||
) -> Option<AnyElement> {
|
||||
let p = self.worktree_prompt.as_ref()?;
|
||||
let muted = cx.theme().muted_foreground;
|
||||
let field = |label: &'static str, input: &Entity<InputState>| {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(div().text_xs().text_color(muted).child(label))
|
||||
.child(Input::new(input).small())
|
||||
};
|
||||
let name_now = p.name.read(cx).value().trim().to_string();
|
||||
let branch_now = p.branch.read(cx).value().trim().to_string();
|
||||
// Submitting falls back from one field to the other, so either alone
|
||||
@@ -164,54 +157,59 @@ impl Tty7App {
|
||||
.display()
|
||||
.to_string();
|
||||
|
||||
let card = v_flex()
|
||||
.occlude()
|
||||
.w(px(440.))
|
||||
.gap_4()
|
||||
.p_5()
|
||||
.map(|panel| crate::ui::theme::floating_surface(panel, cx))
|
||||
let rungs = dialog::popover_rungs(cx);
|
||||
let card = dialog::card(440., cx)
|
||||
.child(dialog::header(t(L10nKey::WorktreePromptTitle), cx))
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||||
.child(t(L10nKey::WorktreePromptTitle)),
|
||||
)
|
||||
.child(field(t(L10nKey::WorktreePromptName), &p.name))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_family("monospace")
|
||||
.text_color(muted)
|
||||
.child(preview),
|
||||
)
|
||||
.child(field(t(L10nKey::WorktreePromptBranch), &p.branch))
|
||||
.child(field(t(L10nKey::WorktreePromptBase), &p.base))
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_end()
|
||||
.gap_2()
|
||||
dialog::body()
|
||||
// The path preview hangs off the Name field it follows,
|
||||
// closer to it than the next field is.
|
||||
.child(
|
||||
Button::new("worktree-cancel")
|
||||
.label(t(L10nKey::Cancel))
|
||||
.small()
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.cancel_worktree_prompt(window, cx)
|
||||
})),
|
||||
dialog::labelled(t(L10nKey::WorktreePromptName), Input::new(&p.name), cx)
|
||||
.child(
|
||||
div()
|
||||
.truncate()
|
||||
.text_size(rems(META_MONO))
|
||||
.font_family("monospace")
|
||||
.text_color(muted)
|
||||
.child(preview),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new("worktree-create")
|
||||
.label(if p.busy {
|
||||
t(L10nKey::WorktreePromptCreating)
|
||||
} else {
|
||||
t(L10nKey::WorktreePromptCreate)
|
||||
})
|
||||
.small()
|
||||
.primary()
|
||||
.disabled(p.busy || nothing_to_name)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.submit_worktree_prompt(window, cx)
|
||||
})),
|
||||
),
|
||||
.child(dialog::labelled(
|
||||
t(L10nKey::WorktreePromptBranch),
|
||||
Input::new(&p.branch),
|
||||
cx,
|
||||
))
|
||||
.child(dialog::labelled(
|
||||
t(L10nKey::WorktreePromptBase),
|
||||
Input::new(&p.base),
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
dialog::footer(cx)
|
||||
.child(dialog::button(
|
||||
"worktree-cancel",
|
||||
t(L10nKey::Cancel),
|
||||
Tone::Secondary,
|
||||
true,
|
||||
rungs,
|
||||
cx,
|
||||
cx.listener(|this, _, window, cx| this.cancel_worktree_prompt(window, cx)),
|
||||
))
|
||||
.child(dialog::button(
|
||||
"worktree-create",
|
||||
if p.busy {
|
||||
t(L10nKey::WorktreePromptCreating)
|
||||
} else {
|
||||
t(L10nKey::WorktreePromptCreate)
|
||||
},
|
||||
Tone::Primary,
|
||||
!(p.busy || nothing_to_name),
|
||||
rungs,
|
||||
cx,
|
||||
cx.listener(|this, _, window, cx| this.submit_worktree_prompt(window, cx)),
|
||||
)),
|
||||
);
|
||||
|
||||
Some(
|
||||
|
||||
Reference in New Issue
Block a user