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:
l0ng-ai
2026-09-24 20:24:20 +08:00
parent c9953761d5
commit 6c6f5dd009
7 changed files with 625 additions and 326 deletions
+10
View File
@@ -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 错误/成功配色;应用设计调整不得反转状态含义。
## 验证要求
+249
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(