diff --git a/crates/tty7-core/src/core/session.rs b/crates/tty7-core/src/core/session.rs index d7aa682f..32218798 100644 --- a/crates/tty7-core/src/core/session.rs +++ b/crates/tty7-core/src/core/session.rs @@ -369,6 +369,11 @@ pub struct WindowView { pub label: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub subject: Option, + /// A reference mirrored off its machine's own listing at connect time — + /// this client has never opened it. Launch restore skips these (its clock + /// is another client's activity, not ours); opening one clears the mark. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub synced: bool, } impl Default for WindowView { @@ -381,6 +386,7 @@ impl Default for WindowView { host: None, label: None, subject: None, + synced: false, } } } @@ -471,8 +477,12 @@ impl WindowViews { .map(|w| w.id) }) .or_else(|| { + // Never a synced reference: its clock is another client's + // activity, and "restore" landing on a workspace this client + // has never opened would dial a machine unasked at launch. self.views .iter() + .filter(|w| !w.synced) .max_by_key(|w| w.last_active) .map(|w| w.id) }) @@ -956,6 +966,39 @@ mod tests { ); } + #[test] + fn launch_restore_never_lands_on_a_synced_reference() { + // A synced entry's clock is another client's activity; restoring it + // would dial its machine unasked at launch. + let mut synced = view(); + synced.open = false; + synced.synced = true; + synced.last_active = 999; + let mut mine = view(); + mine.open = false; + mine.last_active = 10; + let mine_id = mine.id; + + let all = WindowViews { + active: None, + views: vec![synced, mine], + }; + assert_eq!(all.workspace_to_restore(), Some(mine_id)); + + let mut only_synced = view(); + only_synced.open = false; + only_synced.synced = true; + let all = WindowViews { + active: None, + views: vec![only_synced], + }; + assert_eq!( + all.workspace_to_restore(), + None, + "nothing of this client's own to restore starts fresh instead" + ); + } + #[test] fn launch_restores_the_focused_workspace_not_the_most_recently_touched() { let mut focused = view(); diff --git a/docs/getting-started/concepts.mdx b/docs/getting-started/concepts.mdx index 1f6bb2ec..1029ede8 100644 --- a/docs/getting-started/concepts.mdx +++ b/docs/getting-started/concepts.mdx @@ -30,6 +30,13 @@ A **workspace** is a named set of tabs — a project, usually. One window shows one workspace at a time, and ⌘ ⇧ O opens the switcher to move between them or open a second window on another one. +The switcher is one flat list, most recently used first — local and remote +workspaces together, each row wearing the machine it lives on. Typing filters +by workspace, tab, or machine name, so typing a machine's name is how you see +just that machine. ⌘ ⇧ N opens the New Workspace form, where you +pick the machine (this computer, or any configured SSH host) and optionally a +name. + Workspaces are how tty7 keeps ten repositories from becoming forty indistinguishable tabs. They also travel: a workspace on a remote machine is still a workspace, opened from the same switcher. diff --git a/docs/remote/workspaces.mdx b/docs/remote/workspaces.mdx index f5ca6cf4..bd70c5eb 100644 --- a/docs/remote/workspaces.mdx +++ b/docs/remote/workspaces.mdx @@ -17,14 +17,17 @@ Nothing is synced or copied. The repository stays where it is. ## Connecting - - ⌘ ⇧ O. Machines are listed alongside your local workspaces — - *This Computer* first, then every saved SSH profile and, on Windows, every - WSL distribution. + + ⌘ ⇧ N, or the button at the bottom of the switcher + (⌘ ⇧ O). The form's host dropdown lists *This Computer*, every + saved SSH profile, your `~/.ssh/config` aliases and, on Windows, every WSL + distribution — type to filter when the list is long. tty7 connects over the same SSH stack as everything else, so profiles, - keychain credentials, and jump hosts all apply. + keychain credentials, and jump hosts all apply. A machine that is not + connected yet connects first, then creates the workspace in its home + directory. The first connection asks: @@ -37,8 +40,9 @@ Nothing is synced or copied. The repository stays where it is. agree. Later upgrades on that machine install silently. - From then on the machine's workspaces are in the switcher, and a new one - opens like a local one. + From then on the machine's workspaces sit in the switcher's flat list next + to your local ones, each row carrying the machine's name and link state, + and a new one opens like a local one. diff --git a/src/core/session.rs b/src/core/session.rs index 5769bda0..47dd65c5 100644 --- a/src/core/session.rs +++ b/src/core/session.rs @@ -58,6 +58,7 @@ impl WorkspaceStore { } }; view.open = true; + view.synced = false; view.touch(); let claimed = view.id; store.views.active = Some(claimed); @@ -190,6 +191,9 @@ impl WorkspaceStore { if let (Some(h), Some(via)) = (view.host.as_mut(), host.via.clone()) { h.via = Some(via); } + // Claimed is opened: the reference stops being a mirror of + // someone else's listing and becomes this client's own. + view.synced = false; view.id } None => { @@ -202,6 +206,67 @@ impl WorkspaceStore { store.views.save(); id } + + /// Mirrors one machine's own workspace listing into the store at connect + /// time, so the switcher still knows that machine's workspaces after a + /// restart, link or no link. Listed workspaces the store has never seen + /// get a reference marked `synced` (launch restore skips those); ones it + /// has get their label refreshed; unopened references whose workspace has + /// left the listing — deleted by another client — are dropped. + pub fn sync_remote( + cx: &mut gpui::App, + target: &RemoteTarget, + listing: &[(WorkspaceId, String, u64)], + ) { + let profiles = cx + .try_global::() + .map(|cfg| cfg.ssh_profiles.clone()) + .unwrap_or_default(); + let Some(store) = Self::try_store(cx) else { + return; + }; + for (ws, name, last_active) in listing { + let label = Some(name.trim().to_string()).filter(|n| !n.is_empty()); + match store.views.views.iter_mut().find(|w| { + w.host + .as_ref() + .is_some_and(|h| &h.target == target && h.workspace == *ws) + }) { + Some(view) => { + if !view.open { + if label.is_some() { + view.label = label; + } + // The machine's clock only drives entries this client + // has never used; a used one keeps meaning "when *I* + // last had it open". + if view.synced { + view.last_active = *last_active; + } + } + } + None => { + let mut host = RemoteRef::new(target.clone(), *ws); + host.refresh_via(&profiles); + let mut view = WindowView::on_remote(host); + view.open = false; + view.synced = true; + view.label = label; + view.last_active = *last_active; + store.views.views.push(view); + } + } + } + let listed: std::collections::HashSet = + listing.iter().map(|(ws, ..)| *ws).collect(); + store.views.views.retain(|w| { + let Some(host) = w.host.as_ref() else { + return true; + }; + &host.target != target || w.open || listed.contains(&host.workspace) + }); + store.views.save(); + } } pub(crate) fn host_for(views: &WindowViews, id: WorkspaceId) -> HostId { @@ -366,4 +431,75 @@ mod tests { assert_eq!(local.host_id(), HostId::LOCAL); assert_ne!(a.host_id(), HostId::LOCAL); } + + #[gpui::test] + fn connect_time_sync_mirrors_the_machines_listing(cx: &mut gpui::TestAppContext) { + // `sync_remote` saves; a test has no business writing the real views. + let _ = tty7_core::core::config::set_config_dir( + std::env::temp_dir().join(format!("tty7-session-test-{}", std::process::id())), + ); + cx.update(|cx| { + WorkspaceStore::install_for_test(cx, WindowViews::default()); + let target = RemoteTarget::direct("me", "devbox", 22); + let elsewhere = RemoteTarget::direct("me", "gpu-lab", 22); + let (a, b, c) = (WorkspaceId::new(), WorkspaceId::new(), WorkspaceId::new()); + + // A workspace on another machine must never be touched by this + // machine's sync. + let kept = WorkspaceStore::claim_remote(cx, RemoteRef::new(elsewhere, c)); + + WorkspaceStore::sync_remote( + cx, + &target, + &[(a, "api".into(), 30), (b, "web".into(), 20)], + ); + let synced: Vec<_> = WorkspaceStore::all(cx) + .views + .iter() + .filter(|w| w.synced) + .collect(); + assert_eq!(synced.len(), 2, "both listed workspaces gain a reference"); + assert!( + synced.iter().all(|w| !w.open), + "a mirrored reference is not an open window" + ); + + // The next listing dropped `b` (deleted by another client) and + // renamed `a`: the reference set follows the machine. + WorkspaceStore::sync_remote(cx, &target, &[(a, "api-v2".into(), 50)]); + let store = WorkspaceStore::all(cx); + let of_target: Vec<_> = store + .views + .iter() + .filter(|w| w.host.as_ref().is_some_and(|h| h.workspace == a)) + .collect(); + assert_eq!(of_target.len(), 1); + assert_eq!(of_target[0].label.as_deref(), Some("api-v2")); + assert_eq!(of_target[0].last_active, 50, "a synced clock follows"); + assert!( + !store + .views + .iter() + .any(|w| w.host.as_ref().is_some_and(|h| h.workspace == b)), + "a workspace the machine no longer lists is dropped" + ); + assert!( + store.get(kept).is_some(), + "another machine's entries are not this sync's to prune" + ); + + // Claiming the reference makes it this client's own: the mark + // clears, and later syncs stop driving its clock. + let local = WorkspaceStore::claim_remote(cx, RemoteRef::new(target.clone(), a)); + assert!(!WorkspaceStore::all(cx).get(local).expect("claimed").synced); + WorkspaceStore::sync_remote(cx, &target, &[(a, "api-v3".into(), 99)]); + let view = WorkspaceStore::all(cx).get(local).expect("still there"); + assert_eq!( + view.label.as_deref(), + Some("api-v3"), + "the name still follows the machine" + ); + assert_eq!(view.last_active, 50, "the clock is now this client's own"); + }); + } } diff --git a/src/ui/app.rs b/src/ui/app.rs index 0a3a16e8..974aca85 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -580,6 +580,9 @@ pub struct Tty7App { /// Parked switcher groups (#485) whose notice the user dismissed by key — /// the entries stay, only the "will not reconnect" block is hidden. pub(crate) parked_dismissed: std::collections::HashSet, + /// A create asked of a machine that was not connected yet; the connect + /// finishing is what completes it (see `Tty7App::finish_connect`). + pub(crate) pending_create: Option, /// Why the window opened with no terminal in it. Shown on the home screen, /// which is otherwise indistinguishable from having closed everything. pub(crate) startup_error: Option, @@ -1122,6 +1125,7 @@ impl Tty7App { host_snapshots: std::collections::HashMap::new(), remote_host_errors: std::collections::HashMap::new(), parked_dismissed: std::collections::HashSet::new(), + pending_create: None, startup_error, }; if !cfg!(test) && crate::ui::windows::WindowRegistry::count(cx) == 0 { @@ -4137,7 +4141,7 @@ impl Tty7App { self.bump_command_frecency(&kind, cx); match kind { NewTab => self.new_tab(window, cx), - NewWorkspace => self.switch_workspace(None, window, cx), + NewWorkspace => self.open_workspace_form(window, cx), OpenWorkspacePicker => self.open_switcher(window, cx), StopWorkspace => self.stop_workspace(self.workspace, window, cx), DeleteWorkspace => self.delete_workspace(self.workspace, window, cx), @@ -6437,7 +6441,7 @@ impl Render for Tty7App { this.delete_workspace(id, window, cx); })) .on_action(cx.listener(|this, _: &NewWorkspace, window, cx| { - this.switch_workspace(None, window, cx); + this.open_workspace_form(window, cx); })) .on_action(cx.listener(|this, _: &CloseActiveTab, window, cx| { if !this.editor_close_active_if_focused(window, cx) { diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index a035a8e0..80ce60df 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1307,6 +1307,14 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SwitcherActiveTab => "active", L10nKey::SwitcherHoldToSwitch => "Tab to move · release to switch", L10nKey::SwitcherTabToCrossColumns => "Tab to cross columns", + L10nKey::SwitcherLocalHost => "local", + L10nKey::SwitcherConnectingTo => "Connecting to {machine}…", + L10nKey::SwitcherFormName => "Name", + L10nKey::SwitcherFormHost => "Host", + L10nKey::SwitcherFormNamePlaceholder => "Optional", + L10nKey::SwitcherFormBack => "Back", + L10nKey::SwitcherFormCreateHint => "Enter to create · Esc to go back", + L10nKey::SwitcherFormPickHint => "↑↓ to choose · Enter to select · Esc to close", L10nKey::SshPromptPasswordFor => "Password for {user}@{host}", L10nKey::SshPromptPassphraseFor => "Passphrase for {key_path}", L10nKey::SshPromptTwoFactor => "Two-factor authentication", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index c46c02f5..0891b21d 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1345,6 +1345,14 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SwitcherActiveTab => "アクティブ", L10nKey::SwitcherHoldToSwitch => "Tab で移動 · 離して切り替え", L10nKey::SwitcherTabToCrossColumns => "Tab で列を移動", + L10nKey::SwitcherLocalHost => "ローカル", + L10nKey::SwitcherConnectingTo => "{machine} に接続中…", + L10nKey::SwitcherFormName => "名前", + L10nKey::SwitcherFormHost => "ホスト", + L10nKey::SwitcherFormNamePlaceholder => "任意", + L10nKey::SwitcherFormBack => "戻る", + L10nKey::SwitcherFormCreateHint => "Enter で作成 · Esc で戻る", + L10nKey::SwitcherFormPickHint => "↑↓ で選択 · Enter で決定 · Esc で閉じる", L10nKey::SshPromptPasswordFor => "{user}@{host} のパスワード", L10nKey::SshPromptPassphraseFor => "{key_path} のパスフレーズ", L10nKey::SshPromptTwoFactor => "二要素認証", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 441ebcb8..df5e6acb 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -1055,6 +1055,14 @@ l10n_keys! { SwitcherActiveTab, SwitcherHoldToSwitch, SwitcherTabToCrossColumns, + SwitcherLocalHost, + SwitcherConnectingTo, + SwitcherFormName, + SwitcherFormHost, + SwitcherFormNamePlaceholder, + SwitcherFormBack, + SwitcherFormCreateHint, + SwitcherFormPickHint, SshPromptPasswordFor, SshPromptPassphraseFor, SshPromptTwoFactor, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index d6ad26ea..03a6a6d1 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1223,6 +1223,14 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SwitcherActiveTab => "当前", L10nKey::SwitcherHoldToSwitch => "按 Tab 移动 · 松开切换", L10nKey::SwitcherTabToCrossColumns => "按 Tab 换到另一列", + L10nKey::SwitcherLocalHost => "本机", + L10nKey::SwitcherConnectingTo => "正在连接 {machine}…", + L10nKey::SwitcherFormName => "名字", + L10nKey::SwitcherFormHost => "主机", + L10nKey::SwitcherFormNamePlaceholder => "可选", + L10nKey::SwitcherFormBack => "返回", + L10nKey::SwitcherFormCreateHint => "Enter 创建 · Esc 返回", + L10nKey::SwitcherFormPickHint => "↑↓ 选择 · Enter 确定 · Esc 收起", L10nKey::SshPromptPasswordFor => "{user}@{host} 的密码", L10nKey::SshPromptPassphraseFor => "{key_path} 的密码短语", L10nKey::SshPromptTwoFactor => "双因素认证", diff --git a/src/ui/remote_workspace.rs b/src/ui/remote_workspace.rs index 6a366435..f2603cfd 100644 --- a/src/ui/remote_workspace.rs +++ b/src/ui/remote_workspace.rs @@ -347,7 +347,9 @@ impl Tty7App { .background_executor() .spawn(async move { remote_connect::connect_blocking(&target, header, &label) }) .await; - let _ = this.update(cx, |this, cx| this.finish_connect(result, cx)); + let _ = this.update_in(cx, |this, window, cx| { + this.finish_connect(result, window, cx) + }); }) .detach(); } @@ -386,6 +388,7 @@ impl Tty7App { fn finish_connect( &mut self, result: Result, + window: &mut Window, cx: &mut Context, ) { let Some(choice) = self.connect.as_ref().and_then(ConnectFlow::choice).cloned() else { @@ -404,11 +407,39 @@ impl Tty7App { }, ); remote_connect::HostLinks::insert(cx, connected.host, home.clone()); + // The listing outlives the link: mirrored into the store so + // the switcher still knows this machine's workspaces after a + // restart, without a connection. + let listing: Vec<(WorkspaceId, String, u64)> = rows + .iter() + .map(|r| (r.id, r.name.clone(), r.last_active)) + .collect(); + WorkspaceStore::sync_remote(cx, &choice.target, &listing); self.prompt_remote_daemon_mismatch_later(cx); self.connect = None; + // A create that was waiting on this link (the switcher's form, + // asked of a machine that was not connected yet) can now run: + // the link just told us the home directory to root it at. + if self + .pending_create + .as_ref() + .is_some_and(|p| p.target == choice.target) + { + let pending = self.pending_create.take().expect("checked above"); + self.close_switcher(window, cx); + self.create_remote_workspace(pending.target, home, window, cx); + self.name_fresh_workspace(pending.name, window, cx); + } } Err(error) => { log::warn!("connect to {} failed: {error}", choice.label); + if self + .pending_create + .as_ref() + .is_some_and(|p| p.target == choice.target) + { + self.pending_create = None; + } self.connect = Some(ConnectFlow::Failed { choice, error }); } } @@ -711,9 +742,8 @@ impl Tty7App { // The grouped report is only visible while the switcher is open. Anywhere // else — the window menu's "restart server", or a mismatch raised mid-connect // — the modal is the only thing the user would see, so keep it. - if let (Some(target), Some(switcher)) = (target, self.switcher.as_mut()) { + if let (Some(target), true) = (target, self.switcher.is_some()) { let key = target.to_string(); - switcher.expand(&key); self.remote_host_errors.insert(key, error.to_string()); cx.notify(); return; diff --git a/src/ui/switcher.rs b/src/ui/switcher.rs index 6f73360d..63042c38 100644 --- a/src/ui/switcher.rs +++ b/src/ui/switcher.rs @@ -1,5 +1,4 @@ use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; use gpui::{ AnyElement, App, ClickEvent, Context, Entity, MouseButton, MouseDownEvent, Subscription, @@ -24,6 +23,13 @@ use crate::ui::remote_workspace::{ConnectFlow, MachineStatus, RemoteLinks}; const CARD_W: f32 = 840.0; +/// The create form's card. Narrower than the list — it is a form, not a +/// browser. +const FORM_W: f32 = 480.0; + +/// The host dropdown shows about eight rows before it scrolls. +const FORM_LIST_H: f32 = 8.5 * (ROW_H + 8.0); + const LEFT_W: f32 = 340.0; const CARD_TOP: f32 = 120.0; @@ -44,8 +50,6 @@ const GUTTER: f32 = 26.0; const ICON: f32 = 16.0; -const KID_INDENT: f32 = 16.0; - const ROW_PAD: f32 = 8.0; const PROGRESS_H: f32 = 3.0; @@ -116,7 +120,6 @@ struct Group { endpoint: String, target: Option, link: Link, - home: Option, error: Option, installing: Option, /// Another client is holding at least one workspace of this machine. The @@ -135,6 +138,8 @@ struct Row { name: String, path: String, when: String, + /// Raw timestamp behind `when` — what the flat list sorts by. + last_active: u64, live: Liveness, open: bool, current: bool, @@ -174,25 +179,53 @@ pub(crate) enum Column { Right, } -/// A selectable line in the left column. Rendering and keyboard navigation walk -/// the same list so an arrow key can never land somewhere the eye cannot see. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -enum Nav { - Host(usize), - Row(usize, usize), - OthersHeader, - Other(usize), -} +/// A selectable line in the left column: `(group, row)` into `Layout::groups`. +/// The list is flat — one workspace per line, machines told apart by the badge +/// on the row itself — and rendering and keyboard navigation walk the same +/// list so an arrow key can never land somewhere the eye cannot see. +type Nav = (usize, usize); pub(crate) struct HostSnapshot { pub target: RemoteTarget, pub rows: Vec, } +/// Which face the card is showing: the workspace list, or the create form. +pub(crate) enum Page { + List, + Create(CreateForm), +} + +/// The "New Workspace" form: a name prefilled with what the workspace would +/// have called itself anyway, and a host picked from a combobox that folds +/// however many machines are configured into one row. +pub(crate) struct CreateForm { + name: Entity, + /// The combobox's filter text. Only meaningful while `open`. + host: Entity, + /// Whether the host dropdown is unfolded. + open: bool, + /// Cursor into the dropdown's item list. + sel: usize, + /// The picked host. `None` is this computer. + chosen: Option, + /// What the name box was prefilled with. While its value still says this + /// (or nothing), picking another host refills it; one keystroke of the + /// user's own and it is theirs. + prefill: String, +} + +/// A create the user asked for on a machine that was not connected yet: the +/// connect has to land first, because only a live link knows the home +/// directory a fresh workspace is rooted at. `finish_connect` consumes it. +pub(crate) struct PendingCreate { + pub target: RemoteTarget, + pub name: Option, +} + pub(crate) struct Switcher { pub query: Entity, - collapsed: HashSet, - show_others: bool, + page: Page, renaming: Option<(WorkspaceId, Entity)>, column: Column, left_sel: usize, @@ -219,38 +252,20 @@ impl Switcher { fn text(&self, cx: &App) -> String { self.query.read(cx).value().trim().to_lowercase() } - - pub(crate) fn expand(&mut self, key: &str) { - self.collapsed.remove(key); - } } -/// Everything the panel needs for one frame: the groups, which of their rows -/// survived the search, and the flattened left column. +/// Everything the panel needs for one frame: the groups (one per machine, +/// still the unit that carries link state and errors), and the flat, +/// most-recently-used-first left column the arrow keys walk. struct Layout { groups: Vec, - /// Per group, the row indices the search left visible. `None` hides the - /// whole group. - shown: Vec>>, - others: Vec, - other_hits: Vec, - others_expanded: bool, nav: Vec