fix(ui): SSH auth-sheet polish + softer primary buttons (#79)

* fix(ui): SSH auth-sheet polish + softer primary buttons

- Remember toggle: real Checkbox instead of a full-width ghost button whose
  selected-state fill read as a grey bar across the whole card
- auth sheet: tighten to the shared sheet padding (p_4 / gap_3), width 420
- dead-SSH pane: replace the top-left chip + reconnect notice (which overlaid
  the daemon's red failure line printed at top-left) with a single
  bottom-centered 'Disconnected — ⌘⇧R · Reconnect' bar, clear of the output
- drop the connecting/authenticating top-left SSH chip entirely (the tab
  status dot carries the phase; the buffer shows connect progress)
- soften primary buttons app-wide: fill from foreground nudged ~20% toward
  the background (a dark charcoal, not pure black) via the primary /
  button_primary token family

Pairs with l0ng-ai/gpui-component@9484cf9 (checkbox: instant check, no fade),
picked up by the Cargo.lock bump.

* fix(ui): SFTP parent row is a ".." directory entry, not a "Go up" action

The leading go-up row used an ArrowUp icon + muted "Go up" label, reading as a
toolbar action stranded in the list. Style it like a directory entry — Folder
icon + ".." name in the foreground ink — matching the rows below (the
file-manager/WinRAR convention). Click still goes to the parent.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
l0ng-ai
2026-07-14 16:24:45 +08:00
committed by GitHub
co-authored by l0ng-ai
parent 8695b80c61
commit 41fbed1e30
6 changed files with 97 additions and 94 deletions
Generated
+3 -3
View File
@@ -2922,7 +2922,7 @@ dependencies = [
[[package]]
name = "gpui-component"
version = "0.5.2"
source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#25079a7b2519b1da1f7aced160725a852643b50a"
source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#9484cf99c89ac7bbce7af063c329925a6576409b"
dependencies = [
"aho-corasick",
"anyhow",
@@ -2972,7 +2972,7 @@ dependencies = [
[[package]]
name = "gpui-component-assets"
version = "0.5.1"
source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#25079a7b2519b1da1f7aced160725a852643b50a"
source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#9484cf99c89ac7bbce7af063c329925a6576409b"
dependencies = [
"anyhow",
"gpui",
@@ -2986,7 +2986,7 @@ dependencies = [
[[package]]
name = "gpui-component-macros"
version = "0.5.1"
source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#25079a7b2519b1da1f7aced160725a852643b50a"
source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#9484cf99c89ac7bbce7af063c329925a6576409b"
dependencies = [
"proc-macro2",
"quote",
+46 -68
View File
@@ -10,31 +10,24 @@ use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::input::Input;
use gpui_component::{ActiveTheme as _, IconName, Sizable as _, h_flex, v_flex};
use crate::daemon::protocol::{
ForwardStatus, ManagedForward, RemoteContext, SshForwardKind, SshPhase,
};
use crate::daemon::protocol::{ForwardStatus, ManagedForward, RemoteContext, SshForwardKind};
use crate::terminal::view::TerminalView;
use crate::ui::app::Tty7App;
impl Tty7App {
/// The in-pane native-SSH status strip (PRD FR-E1): a subtle ` SSH ` chip
/// coloured by the connection phase, with the hostname, pinned top-left of the
/// terminal body. A dead pane also shows the "connection lost — ⌘⇧R to
/// reconnect" notice (FR-E4). Returns `None` for a non-native pane.
/// The in-pane native-SSH notice (PRD FR-E4): a dead pane shows a
/// bottom-centered "Disconnected — ⌘⇧R to reconnect" bar. Live/connecting
/// panes show nothing here — the tab status dot already carries the phase and
/// the daemon prints connect progress/failures into the buffer. Returns
/// `None` for a non-native or still-alive pane.
pub(crate) fn render_ssh_status_strip(
&self,
leaf: &Entity<TerminalView>,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
let view = leaf.read(cx);
let phase = view.ssh_phase()?;
let disconnected = view.ssh_disconnected();
// Once connected, the tab status dot already carries the connection state and
// the top-right tunnel/SFTP icons signal "this is an SSH pane" — an in-pane
// chip here would just float over the shell output. Only surface the strip
// while still connecting (blank pane, no overlap) or after a drop (the
// actionable reconnect notice).
if matches!(phase, SshPhase::Connected) && !disconnected {
view.ssh_phase()?;
if !view.ssh_disconnected() {
return None;
}
let host = view
@@ -45,69 +38,54 @@ impl Tty7App {
.unwrap_or_default();
let theme = cx.theme();
// Phase → accent. Connecting/authenticating are cautionary (yellow),
// connected reads calm (accent), failed/disconnected are red.
let (color, label) = if disconnected {
(theme.danger, "SSH ✕")
} else {
match &phase {
SshPhase::Connecting => (theme.warning, "SSH …"),
SshPhase::Authenticating => (theme.warning, "SSH ⚿"),
SshPhase::Connected => (theme.accent, "SSH"),
SshPhase::Failed { .. } => (theme.danger, "SSH ✕"),
}
};
let chip = h_flex()
// The failure reason is already printed into the terminal buffer
// (top-left, in red) by the daemon, so a top-left overlay would sit right
// on top of it. Dock the (actionable) reconnect notice at the
// bottom-center — clear of the output, a familiar "connection lost,
// reconnect" spot.
let bar = h_flex()
.occlude()
.items_center()
.gap_1p5()
.px_2()
.py_0p5()
.rounded_md()
.bg(color.opacity(0.15))
.gap_2()
.px_3()
.py_1p5()
.rounded_lg()
.bg(theme.popover)
.border_1()
.border_color(color.opacity(0.5))
.border_color(theme.danger.opacity(0.4))
.shadow_md()
.text_xs()
.text_color(color)
.child(div().font_weight(FontWeight::SEMIBOLD).child(label))
.when(!host.is_empty(), |d| {
d.child(div().text_color(theme.muted_foreground).child(host))
});
let mut col = div().flex().flex_col().items_start().gap_1().child(chip);
if disconnected {
col = col.child(
h_flex()
.items_center()
.gap_2()
.px_2()
.py_1()
.rounded_md()
.bg(theme.danger.opacity(0.12))
.border_1()
.border_color(theme.danger.opacity(0.4))
.text_xs()
.text_color(theme.muted_foreground)
.child(
div()
.font_weight(FontWeight::MEDIUM)
.text_color(theme.foreground)
.child("Connection lost — press ⌘⇧R to reconnect")
.child(
Button::new("ssh-reconnect")
.label("Reconnect")
.primary()
.small()
.on_click(cx.listener(|this, _, window, cx| {
this.restart_ssh_session(window, cx)
})),
.child(if host.is_empty() {
"Disconnected".to_string()
} else {
format!("Disconnected from {host}")
}),
)
.child(div().child("· ⌘⇧R"))
.child(
Button::new("ssh-reconnect")
.label("Reconnect")
.primary()
.small()
.on_click(
cx.listener(|this, _, window, cx| this.restart_ssh_session(window, cx)),
),
);
}
Some(
div()
.absolute()
.top_2()
.left_4()
.child(col)
.left_0()
.right_0()
.bottom_4()
.flex()
.justify_center()
.child(bar)
.into_any_element(),
)
}
+1 -1
View File
@@ -181,7 +181,7 @@ impl Theme {
}
/// Blend `a` toward `b` by `t` (0.0 = all `a`, 1.0 = all `b`), per channel.
fn mix(a: u32, b: u32, t: f32) -> u32 {
pub(crate) fn mix(a: u32, b: u32, t: f32) -> u32 {
let (ar, ag, ab) = (a >> 16 & 0xff, a >> 8 & 0xff, a & 0xff);
let (br, bg, bb) = (b >> 16 & 0xff, b >> 8 & 0xff, b & 0xff);
let ch = |x: u32, y: u32| (x as f32 + (y as f32 - x as f32) * t).round() as u32;
+10 -8
View File
@@ -13,7 +13,7 @@
//! whose root reads `SFTP` and which double-clicks into a "type a path" text
//! input; a toolbar (refresh / filter / new folder / upload / go-to-shell-cwd);
//! a filter box hidden behind the toolbar's Filter toggle; a dir-first entry
//! list led by a `Go up` row (when not at the root) whose per-row actions
//! list led by a `..` parent row (when not at the root) whose per-row actions
//! (open/download / follow-symlink / rename / chmod / delete) live in a
//! right-click context menu (PRD §6.3: hotkeys + right-click, not a permanent
//! toolbar); an inline edit form; and a bottom transfer tray that polls job
@@ -1068,8 +1068,8 @@ impl Tty7App {
let filter = self.sftp_panel.filter_input.read(cx).value().to_string();
let entries = sorted_filtered_entries(&self.sftp_panel.entries, &filter);
// "Go up" leads the list (tabby-style) when not at the root and not
// actively filtering — it replaces the old toolbar "Up" button.
// A `..` parent row leads the list when not at the root and not
// actively filtering — the file-manager convention for going up.
let show_go_up = self.sftp_panel.cwd != "/" && filter.trim().is_empty();
if entries.is_empty() && !show_go_up {
@@ -1093,9 +1093,11 @@ impl Tty7App {
container.child(list)
}
/// The leading "⤴ Go up" list row (shown when not at the filesystem root).
/// The leading `..` parent row (shown when not at the filesystem root), styled
/// like a directory entry (WinRAR/file-manager convention) so it reads as
/// "the parent folder" and matches the rows below rather than a toolbar action.
fn render_sftp_go_up_row(&self, cx: &mut Context<Self>) -> AnyElement {
let muted = cx.theme().muted_foreground;
let foreground = cx.theme().foreground;
let list_hover = cx.theme().list_hover;
h_flex()
.id("sftp-go-up")
@@ -1106,14 +1108,14 @@ impl Tty7App {
.rounded_md()
.cursor_pointer()
.hover(|s| s.bg(list_hover))
.child(Icon::new(IconName::ArrowUp).small().text_color(muted))
.child(Icon::new(IconName::Folder).small().text_color(foreground))
.child(
div()
.flex_1()
.min_w_0()
.text_sm()
.text_color(muted)
.child("Go up"),
.text_color(foreground)
.child(".."),
)
.on_click(cx.listener(|this, _, _w, cx| this.sftp_up(cx)))
.into_any_element()
+15 -14
View File
@@ -21,8 +21,9 @@ use gpui::{
Subscription, Window, div, prelude::*, px,
};
use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::checkbox::Checkbox;
use gpui_component::input::{Input, InputEvent, InputState};
use gpui_component::{ActiveTheme as _, Selectable 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};
@@ -668,9 +669,9 @@ impl Tty7App {
.occlude()
.track_focus(&self.ssh_prompt.focus_handle)
.key_context("SshPrompt")
.w(px(460.))
.gap_2()
.p_3()
.w(px(420.))
.gap_3()
.p_4()
.bg(cx.theme().popover)
.border_1()
.rounded_lg()
@@ -835,16 +836,16 @@ impl Tty7App {
}
fn render_ssh_remember(&self, cx: &mut Context<Self>) -> AnyElement {
Button::new("ssh-remember")
.label(if self.ssh_prompt.remember {
"☑ Remember (keychain)"
} else {
"☐ Remember (keychain)"
})
.small()
.ghost()
.selected(self.ssh_prompt.remember)
.on_click(cx.listener(|this, _, _w, cx| this.toggle_ssh_remember(cx)))
// A real checkbox, left-aligned in its own row. The old ghost Button
// stretched to the card's full width, so its selected-state fill read as
// a full-width grey bar rather than a checkbox.
h_flex()
.child(
Checkbox::new("ssh-remember")
.label("Remember (keychain)")
.checked(self.ssh_prompt.remember)
.on_click(cx.listener(|this, _, _w, cx| this.toggle_ssh_remember(cx))),
)
.into_any_element()
}
+22
View File
@@ -136,6 +136,28 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) {
t.tokens.accent = Hsla::from(accent_fill).into();
t.tokens.accent_foreground = accent_text.into();
// Primary buttons (Connect, Reconnect, Trust, Save…) fill from
// `tokens.button_primary`; the raw default is the foreground — a pure
// near-black in a light theme, which reads harsh. Nudge it toward the
// background so it lands on a softer dark charcoal (and, symmetrically, a
// slightly dimmed near-white in dark themes). We set the whole primary token
// family (both the plain `primary*` fields — used for the border and outline
// text — and the `button_primary*` tokens the fill actually reads) so every
// primary button shifts together, hover/pressed included. The stock
// `button_primary_foreground` stays legible on top either way.
let primary_base: Hsla = rgb(presets::mix(m.foreground, m.background, 0.20)).into();
let primary_hover: Hsla = rgb(presets::mix(m.foreground, m.background, 0.30)).into();
let primary_active: Hsla = rgb(presets::mix(m.foreground, m.background, 0.10)).into();
t.primary = primary_base;
t.primary_hover = primary_hover;
t.primary_active = primary_active;
t.tokens.primary = primary_base.into();
t.tokens.primary_hover = primary_hover.into();
t.tokens.primary_active = primary_active.into();
t.tokens.button_primary = primary_base.into();
t.tokens.button_primary_hover = primary_hover.into();
t.tokens.button_primary_active = primary_active.into();
t.caret = rgb(m.caret).into();
t.selection = rgb(m.selection).into(); // text selection highlight