mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
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>
This commit is contained in:
@@ -369,6 +369,11 @@ pub struct WindowView {
|
||||
pub label: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub subject: Option<String>,
|
||||
/// 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();
|
||||
|
||||
@@ -30,6 +30,13 @@ A **workspace** is a named set of tabs — a project, usually. One window shows
|
||||
one workspace at a time, and <kbd>⌘ ⇧ O</kbd> 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. <kbd>⌘ ⇧ N</kbd> 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.
|
||||
|
||||
@@ -17,14 +17,17 @@ Nothing is synced or copied. The repository stays where it is.
|
||||
## Connecting
|
||||
|
||||
<Steps>
|
||||
<Step title="Open the switcher">
|
||||
<kbd>⌘ ⇧ O</kbd>. Machines are listed alongside your local workspaces —
|
||||
*This Computer* first, then every saved SSH profile and, on Windows, every
|
||||
WSL distribution.
|
||||
<Step title="Open the New Workspace form">
|
||||
<kbd>⌘ ⇧ N</kbd>, or the button at the bottom of the switcher
|
||||
(<kbd>⌘ ⇧ O</kbd>). 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.
|
||||
</Step>
|
||||
<Step title="Pick a machine">
|
||||
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.
|
||||
</Step>
|
||||
<Step title="Approve the server install, once">
|
||||
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.
|
||||
</Step>
|
||||
<Step title="Open a workspace">
|
||||
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.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
|
||||
@@ -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::<crate::core::config::Config>()
|
||||
.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<WorkspaceId> =
|
||||
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");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -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<String>,
|
||||
/// 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<crate::ui::switcher::PendingCreate>,
|
||||
/// 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<gpui::SharedString>,
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 => "二要素認証",
|
||||
|
||||
@@ -1055,6 +1055,14 @@ l10n_keys! {
|
||||
SwitcherActiveTab,
|
||||
SwitcherHoldToSwitch,
|
||||
SwitcherTabToCrossColumns,
|
||||
SwitcherLocalHost,
|
||||
SwitcherConnectingTo,
|
||||
SwitcherFormName,
|
||||
SwitcherFormHost,
|
||||
SwitcherFormNamePlaceholder,
|
||||
SwitcherFormBack,
|
||||
SwitcherFormCreateHint,
|
||||
SwitcherFormPickHint,
|
||||
SshPromptPasswordFor,
|
||||
SshPromptPassphraseFor,
|
||||
SshPromptTwoFactor,
|
||||
|
||||
@@ -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 => "双因素认证",
|
||||
|
||||
@@ -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<remote_connect::Connected, String>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
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;
|
||||
|
||||
+1229
-908
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user