From 3c0a700907a63baebeacda638ce46fbafb5eeaca Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Thu, 13 Aug 2026 17:35:58 +0800 Subject: [PATCH] feat(switcher): flat workspace list, create form, connect-time workspace sync (#616) * feat(switcher): flatten the workspace list, add a create form, sync remote listings on connect The switcher's left column is now one flat most-recently-used list across all machines, each row carrying its machine and link state; the per-machine tree, headers, and the Other Machines band are gone. Machine trouble (install progress, connect errors, parked routes) moves to contextual banners under the list, and machine verbs move into each row's menu. Cmd+Shift+N now opens a create form instead of silently swapping the workspace: a name prefilled with the usual generated codename, and a host combobox (searchable dropdown) defaulting to this computer. Creating on a machine with no live link connects first and completes when the link is up. Connecting to a machine also mirrors its workspace listing into the local store, so its workspaces survive a restart without a connection. Mirrored references are marked synced: launch restore skips them, and their clock follows the machine only until this client opens them. * fix(switcher): drop a parked create when its machine's connect is called off Disconnect clears the in-flight connect, so finish_connect never runs and the PendingCreate outlived the intent behind it: the next successful connect to that machine would have silently created a workspace nobody was waiting for anymore. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- crates/tty7-core/src/core/session.rs | 43 + docs/getting-started/concepts.mdx | 7 + docs/remote/workspaces.mdx | 18 +- src/core/session.rs | 136 ++ src/ui/app.rs | 8 +- src/ui/i18n/en.rs | 8 + src/ui/i18n/ja.rs | 8 + src/ui/i18n/mod.rs | 8 + src/ui/i18n/zh.rs | 8 + src/ui/remote_workspace.rs | 36 +- src/ui/switcher.rs | 2137 +++++++++++++++----------- 11 files changed, 1497 insertions(+), 920 deletions(-) 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