From cebd871cb4b3814356c63a59e3a3a4c6f4d4d300 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:19:05 +0800 Subject: [PATCH] feat(sidebar): add projects as a declared layer beside the derived groups (#769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sidebar): add projects as a declared layer beside the derived groups The sidebar's repo groups are derived: a group's identity is a path recomputed every frame from a leaf's cwd, it appears when a tab lands in it and vanishes with its last tab. That layer cannot carry a name of its own, cannot be created before a tab is opened in it, and orphans anything keyed to it when a directory is renamed or moved. Add a Project as a real entity on the workspace — an id, an optional name, a root — and an optional reference to one on each tab. Nothing probes it: a tab joins a project only by an explicit action, and a tab that leaves one lands back in the group the probe would have put it in, so declaration and inference never disagree and no third membership state is needed. The derived grouping, the cwd probe, the write-back and the SidebarGrouping config are untouched; the only difference is the tab list they are fed. A server that predates the projects feature ignores the new array and serves today's sidebar. Closes #756 * fix(sidebar): stop a searched row claiming a chord it does not own A live search deliberately ignores a folded heading — the query is asking about tabs — so the rail draws rows the chord order has taken out. The badge was read from a `Vec` that started at zero, so every one of those rows claimed ⌘1 while ⌘1 opened something else. It is `Option` now, built by `badge_positions` off the same order `activate_visual` walks, and a row the order left out wears no badge at all. Also in the rail: the block loop reads "declared" off the section key rather than the position it happens to sit at, and an unreachable `continue` for a folded empty derived block is gone — `sidebar_sections` never makes one. Projects: - `MAX_PROJECTS` is held on the window side too. The machine refuses past it and a refusal resynchronizes, which would re-push the project this window kept and be refused again. Checked before the folder panel opens, so a full workspace says so before asking for a folder rather than after. - `set_project_root` keeps the one-project-per-directory rule `declare_project` holds on the way in; pointing one project at another's folder reached the two-headers-that-mean-the-same-thing state by the back door. - Opening a rename box over one already on another project commits it instead of dropping it with its subscription, which threw the typing away. Sync: - Project reordering moves after `retire_projects`. `to` indexes the machine's whole list, so a project on its way out pushed the survivors along and spelled a move for one already in place. - `adopt_projects` reports whether it changed anything and the callers repaint when it did; it was mutating the window's list with nothing to notify. - `migrate_panes` gets its doc comment back — `reconcile_projects` had been inserted between it and the comment describing it. Dead `L10nKey::ProjectNew` removed: translated four times, used nowhere. * fix(control): move the dialect to v8 for the project verbs `CONTROL_VERSION`'s own doc says to move it whenever a variant is added to `ControlRequest`, `ReplyOk` or `ControlEvent`, and says why the feature strings are not a substitute: they cover what a peer can safely ignore — a field added to a message it already decodes — while a variant it has never heard of fails to decode and takes the whole link down with it. The project verbs shipped behind a `projects` feature string instead. That gates what a client *sends*, so a v7 server never saw a verb it could not read, but nothing gates what a server *pushes*: a v7 client meeting a v8 server that had grown a project would take the `ProjectCreated` delta, fail to decode the frame, and lose the link — `read_until_closed` calls `fail_all` on any decode error. Only the number can turn that pairing away at the handshake. So the number moves and the feature goes. It was redundant even for the direction it did cover: `MACHINE_TREE` and `PROJECTS` were pushed under the same `services.machine.is_some()`, so within one build they were always equal and only a cross-version pairing could tell them apart — which is exactly what v8 now refuses at the handshake. Keeping both would be two mechanisms for one job, and the weaker one silently covering half the problem. Removed with it: `is_project_op` and the `pump` filter it fed. Disk compatibility is a separate axis and is untouched — `Workspace::projects` and `Tab::project` keep their `serde(default)`, and the test that reads a tree written before either still passes. Remote workspaces need their `tty7-server` pushed before they will connect. That is the dialect-refusal path v7 was minted to make reachable: the parked strip and its Update Server button. Also: the two sidebar `+` buttons now fade in on their own heading's hover rather than the whole rail's, so a control appears where the pointer is. --- crates/tty7-cli/src/testbed.rs | 5 + crates/tty7-core/src/core/machine.rs | 377 +++++++++ crates/tty7-core/src/core/session.rs | 6 + crates/tty7-core/src/daemon/control.rs | 51 +- crates/tty7-core/src/daemon/install/mod.rs | 2 +- crates/tty7-core/src/host/server.rs | 58 ++ src/ui/app.rs | 47 +- src/ui/home.rs | 7 + src/ui/i18n/en.rs | 13 + src/ui/i18n/ja.rs | 15 + src/ui/i18n/mod.rs | 13 + src/ui/i18n/zh.rs | 13 + src/ui/machine_mirror.rs | 62 ++ src/ui/mod.rs | 1 + src/ui/projects.rs | 388 ++++++++++ src/ui/reorder.rs | 3 +- src/ui/tab_sidebar.rs | 856 ++++++++++++++++++--- src/ui/tab_strip.rs | 184 ++++- src/ui/tree_sync.rs | 682 +++++++++++++++- 19 files changed, 2595 insertions(+), 188 deletions(-) create mode 100644 src/ui/projects.rs diff --git a/crates/tty7-cli/src/testbed.rs b/crates/tty7-cli/src/testbed.rs index 22cf5c24..735760ba 100644 --- a/crates/tty7-cli/src/testbed.rs +++ b/crates/tty7-cli/src/testbed.rs @@ -6,17 +6,20 @@ pub fn two_workspace_machine() -> Machine { id: WorkspaceId::new(), name: Some("api".into()), last_active: 0, + projects: Vec::new(), tabs: vec![ Tab { id: TabId::new(), name: Some("build".into()), sidebar_group: None, + project: None, root: PaneNode::Leaf { pane: 1 }, }, Tab { id: TabId::new(), name: None, sidebar_group: None, + project: None, root: PaneNode::Split { axis: Axis::Horizontal, ratio: 0.5, @@ -32,10 +35,12 @@ pub fn two_workspace_machine() -> Machine { id: WorkspaceId::new(), name: Some("web".into()), last_active: 0, + projects: Vec::new(), tabs: vec![Tab { id: TabId::new(), name: None, sidebar_group: None, + project: None, root: PaneNode::Leaf { pane: 5 }, }], active_tab: None, diff --git a/crates/tty7-core/src/core/machine.rs b/crates/tty7-core/src/core/machine.rs index 4e754afa..417f47f1 100644 --- a/crates/tty7-core/src/core/machine.rs +++ b/crates/tty7-core/src/core/machine.rs @@ -20,6 +20,8 @@ pub const MAX_WORKSPACES: usize = 1024; pub const MAX_PANES: usize = 16 * 1024; +pub const MAX_PROJECTS: usize = 512; + #[cfg(not(test))] pub const FACT_FLUSH_INTERVAL: Duration = Duration::from_secs(2); @@ -48,6 +50,61 @@ impl std::fmt::Display for TabId { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ProjectId(uuid::Uuid); + +impl ProjectId { + pub fn new() -> Self { + Self(uuid::Uuid::new_v4()) + } +} + +impl Default for ProjectId { + fn default() -> Self { + Self::new() + } +} + +impl std::fmt::Display for ProjectId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +/// A directory someone declared a project, held on the workspace it belongs to. +/// +/// The sidebar's repo groups are derived: their identity *is* a path, they +/// appear when a tab lands in one and vanish with its last tab. A project is +/// the opposite of all three — it has an id of its own, it is created by an +/// explicit act, and it outlives having no tabs in it. That is what lets it +/// carry a name that has nothing to do with where it lives, and survive the +/// directory being renamed or moved. +/// +/// `name` is `None` until someone names it, and reads as a title derived from +/// `root` the same way a group header does. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Project { + #[serde(default)] + pub id: ProjectId, + #[serde(default)] + pub name: Option, + /// A path on the machine that serves this workspace, as a string for the + /// same reason `Tab::sidebar_group` is one: it is written on one machine + /// and read on another, and a `PathBuf` does not survive that crossing. + pub root: String, +} + +impl Project { + pub fn at(root: impl Into) -> Project { + Project { + id: ProjectId::new(), + name: None, + root: root.into(), + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Axis { @@ -102,6 +159,12 @@ pub struct Workspace { pub name: Option, #[serde(default)] pub last_active: u64, + /// The projects declared in this workspace, in the order the sidebar shows + /// them. Empty on every workspace that predates them, and on every one + /// nobody has declared anything in — which is why the derived grouping + /// underneath stays exactly as it was. + #[serde(default)] + pub projects: Vec, #[serde(default)] pub tabs: Vec, #[serde(default)] @@ -120,6 +183,7 @@ impl Default for Workspace { id: WorkspaceId::new(), name: None, last_active: unix_now(), + projects: Vec::new(), tabs: Vec::new(), active_tab: None, attachment: None, @@ -135,6 +199,16 @@ pub struct Tab { pub name: Option, #[serde(default)] pub sidebar_group: Option, + /// The project this tab was explicitly filed under, if any. + /// + /// Only a user action writes this — nothing probes a cwd to fill it in. + /// Automatic filing is what `sidebar_group` above already does, one + /// section further down the sidebar, and a tab pulled out of a project + /// lands right back in the group that probe puts it in. That is why + /// `None` is enough to say "not in a project" and no third state is + /// needed to say "and don't put it back". + #[serde(default)] + pub project: Option, pub root: PaneNode, } @@ -144,6 +218,7 @@ impl Tab { id: TabId::new(), name: None, sidebar_group: None, + project: None, root: PaneNode::Leaf { pane }, } } @@ -408,6 +483,34 @@ pub enum LayoutDelta { tab: TabId, group: Option, }, + ProjectCreated { + at: usize, + project: Project, + }, + ProjectRenamed { + project: ProjectId, + name: Option, + }, + ProjectRerooted { + project: ProjectId, + root: String, + }, + ProjectMoved { + project: ProjectId, + to: usize, + }, + /// The project is gone, and with it every tab's membership in it. One + /// delta rather than a delete plus a `TabProjectChanged` per member: a + /// reader that applies this has to clear those references anyway, and a + /// reader that missed the follow-ups would be left pointing at a project + /// that no longer exists. + ProjectDeleted { + project: ProjectId, + }, + TabProjectChanged { + tab: TabId, + project: Option, + }, TabRestructured { tab: Tab, pane: Option, @@ -742,6 +845,157 @@ impl MachineStore { }) } + pub fn project_create( + &self, + workspace: WorkspaceId, + at: Option, + project: Project, + origin: Option, + ) -> io::Result { + self.mutate(origin, |m| { + if m.workspaces + .iter() + .any(|w| w.projects.iter().any(|p| p.id == project.id)) + { + return Err(refuse(format!("project {} already exists", project.id))); + } + let ws = find_workspace(m, workspace)?; + if ws.projects.len() >= MAX_PROJECTS { + return Err(refuse(format!( + "this workspace already holds {MAX_PROJECTS} projects" + ))); + } + let at = at.unwrap_or(ws.projects.len()).min(ws.projects.len()); + ws.projects.insert(at, project.clone()); + Ok(( + project.clone(), + vec![(workspace, LayoutDelta::ProjectCreated { at, project })], + )) + }) + } + + pub fn project_rename( + &self, + workspace: WorkspaceId, + project: ProjectId, + name: Option, + origin: Option, + ) -> io::Result<()> { + self.mutate(origin, |m| { + let p = find_project(m, workspace, project)?; + p.name = name.clone(); + Ok(( + (), + vec![(workspace, LayoutDelta::ProjectRenamed { project, name })], + )) + }) + } + + /// Points a project at another directory. The name survives, which is the + /// whole reason a project is not its path: a worktree that moved is the + /// same project afterwards. + pub fn project_set_root( + &self, + workspace: WorkspaceId, + project: ProjectId, + root: String, + origin: Option, + ) -> io::Result<()> { + self.mutate(origin, |m| { + let p = find_project(m, workspace, project)?; + p.root = root.clone(); + Ok(( + (), + vec![(workspace, LayoutDelta::ProjectRerooted { project, root })], + )) + }) + } + + pub fn project_move( + &self, + workspace: WorkspaceId, + project: ProjectId, + to: usize, + origin: Option, + ) -> io::Result<()> { + self.mutate(origin, |m| { + let ws = find_workspace(m, workspace)?; + let from = ws + .projects + .iter() + .position(|p| p.id == project) + .ok_or_else(|| { + not_found(format!("workspace {workspace} has no project {project}")) + })?; + let moved = ws.projects.remove(from); + let to = to.min(ws.projects.len()); + ws.projects.insert(to, moved); + Ok(( + (), + vec![(workspace, LayoutDelta::ProjectMoved { project, to })], + )) + }) + } + + /// Deletes a project and lets its tabs go. The tabs themselves stay open — + /// deleting a project says the grouping is over, not the work. + pub fn project_delete( + &self, + workspace: WorkspaceId, + project: ProjectId, + origin: Option, + ) -> io::Result<()> { + self.mutate(origin, |m| { + let ws = find_workspace(m, workspace)?; + let at = ws + .projects + .iter() + .position(|p| p.id == project) + .ok_or_else(|| { + not_found(format!("workspace {workspace} has no project {project}")) + })?; + ws.projects.remove(at); + for tab in &mut ws.tabs { + if tab.project == Some(project) { + tab.project = None; + } + } + Ok(( + (), + vec![(workspace, LayoutDelta::ProjectDeleted { project })], + )) + }) + } + + pub fn tab_set_project( + &self, + workspace: WorkspaceId, + tab: TabId, + project: Option, + origin: Option, + ) -> io::Result<()> { + self.mutate(origin, |m| { + let ws = find_workspace(m, workspace)?; + if let Some(project) = project + && !ws.projects.iter().any(|p| p.id == project) + { + return Err(not_found(format!( + "workspace {workspace} has no project {project}" + ))); + } + let t = ws + .tabs + .iter_mut() + .find(|t| t.id == tab) + .ok_or_else(|| not_found(format!("workspace {workspace} has no tab {tab}")))?; + t.project = project; + Ok(( + (), + vec![(workspace, LayoutDelta::TabProjectChanged { tab, project })], + )) + }) + } + pub fn pane_split( &self, workspace: WorkspaceId, @@ -1151,6 +1405,18 @@ fn find_tab(m: &mut Machine, workspace: WorkspaceId, tab: TabId) -> io::Result<& .ok_or_else(|| not_found(format!("workspace {workspace} has no tab {tab}"))) } +fn find_project( + m: &mut Machine, + workspace: WorkspaceId, + project: ProjectId, +) -> io::Result<&mut Project> { + let ws = find_workspace(m, workspace)?; + ws.projects + .iter_mut() + .find(|p| p.id == project) + .ok_or_else(|| not_found(format!("workspace {workspace} has no project {project}"))) +} + fn heal_active_tab(ws: &mut Workspace, removed: usize) -> Option { let named = ws .active_tab @@ -1964,6 +2230,117 @@ mod tests { ); } + #[test] + fn a_project_outlives_its_tabs_and_keeps_its_name_when_it_moves() { + let (store, _dir, ws, tab) = store_with_tab(); + let project = store + .project_create(ws, None, Project::at("/repo/tty7"), None) + .unwrap(); + store + .project_rename(ws, project.id, Some("套利研究".into()), None) + .unwrap(); + store + .tab_set_project(ws, tab.id, Some(project.id), None) + .unwrap(); + + // The worktree moved. The project is not its path, so the name stays. + store + .project_set_root(ws, project.id, "/repo/026/tty7".into(), None) + .unwrap(); + let held = store.workspace(ws).unwrap(); + assert_eq!(held.projects[0].name.as_deref(), Some("套利研究")); + assert_eq!(held.projects[0].root, "/repo/026/tty7"); + assert_eq!(held.tabs[0].project, Some(project.id)); + + // The last tab in it closes; the project is still there, empty. + store.tab_close(ws, tab.id, None).unwrap(); + let held = store.workspace(ws).unwrap(); + assert!(held.tabs.is_empty()); + assert_eq!( + held.projects.len(), + 1, + "a project is declared, so it does not vanish with its last tab" + ); + } + + #[test] + fn deleting_a_project_lets_its_tabs_go_without_closing_them() { + let (store, _dir, ws, tab) = store_with_tab(); + let project = store + .project_create(ws, None, Project::at("/repo/tty7"), None) + .unwrap(); + store + .tab_set_project(ws, tab.id, Some(project.id), None) + .unwrap(); + + let (_sub, heard) = recorded(&store); + store.project_delete(ws, project.id, None).unwrap(); + let held = store.workspace(ws).unwrap(); + assert!(held.projects.is_empty()); + assert_eq!(held.tabs.len(), 1, "the work stays; only the grouping ends"); + assert_eq!(held.tabs[0].project, None); + assert!( + matches!( + heard.lock().unwrap().as_slice(), + [(_, LayoutDelta::ProjectDeleted { .. })] + ), + "one delta says both halves of it: a reader has to clear the \ + references anyway, and follow-ups it missed would leave it \ + pointing at a project that is gone" + ); + } + + #[test] + fn a_tab_cannot_be_filed_under_a_project_that_does_not_exist() { + let (store, _dir, ws, tab) = store_with_tab(); + let stranger = ProjectId::new(); + let refused = store.tab_set_project(ws, tab.id, Some(stranger), None); + assert_eq!(refused.unwrap_err().kind(), io::ErrorKind::NotFound); + assert_eq!(store.workspace(ws).unwrap().tabs[0].project, None); + } + + #[test] + fn projects_reorder_and_refuse_a_duplicate_id() { + let (store, _dir, ws, _tab) = store_with_tab(); + let a = store + .project_create(ws, None, Project::at("/w/a"), None) + .unwrap(); + let b = store + .project_create(ws, None, Project::at("/w/b"), None) + .unwrap(); + store.project_move(ws, b.id, 0, None).unwrap(); + assert_eq!( + store + .workspace(ws) + .unwrap() + .projects + .iter() + .map(|p| p.root.clone()) + .collect::>(), + vec!["/w/b", "/w/a"] + ); + + let twice = store.project_create(ws, None, a.clone(), None); + assert_eq!(twice.unwrap_err().kind(), io::ErrorKind::InvalidInput); + } + + /// A workspace written before projects existed reads back with none, and a + /// tab written before them belongs to none — which is the whole of the + /// migration. + #[test] + fn a_tree_that_predates_projects_reads_back_unchanged() { + let json = r#"{"workspaces":[{"id":"00000000-0000-4000-8000-000000000001", + "tabs":[{"id":"00000000-0000-4000-8000-000000000002", + "sidebar_group":"/repo/tty7","root":{"Leaf":{"pane":1}}}]}],"panes":[]}"#; + let machine: Machine = serde_json::from_str(json).unwrap(); + assert!(machine.workspaces[0].projects.is_empty()); + assert_eq!(machine.workspaces[0].tabs[0].project, None); + assert_eq!( + machine.workspaces[0].tabs[0].sidebar_group.as_deref(), + Some("/repo/tty7") + ); + } + #[test] fn splitting_and_closing_panes_reshapes_the_tree() { let (store, _dir, ws, tab) = store_with_tab(); diff --git a/crates/tty7-core/src/core/session.rs b/crates/tty7-core/src/core/session.rs index 32218798..207f2f18 100644 --- a/crates/tty7-core/src/core/session.rs +++ b/crates/tty7-core/src/core/session.rs @@ -52,6 +52,8 @@ pub struct SessionTab { pub pane: SessionPane, #[serde(default, skip_serializing_if = "Option::is_none")] pub sidebar_group: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project: Option, #[serde(skip)] pub tree_id: Option, } @@ -61,6 +63,10 @@ pub struct SessionTab { pub struct Session { pub active: usize, pub tabs: Vec, + /// The workspace's declared projects, carried so a window restored from a + /// session alone — no machine to pull a tree from — still shows them. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub projects: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/crates/tty7-core/src/daemon/control.rs b/crates/tty7-core/src/daemon/control.rs index 4172676c..271477f4 100644 --- a/crates/tty7-core/src/daemon/control.rs +++ b/crates/tty7-core/src/daemon/control.rs @@ -34,7 +34,17 @@ use super::protocol::{MAX_FRAME, read_frame, write_frame}; /// refusing to kill a server it has nothing to replace with — can be exercised /// against the v6 servers already deployed. A number is the only way to reach /// that path, and a mismatch nobody can reproduce is a mismatch nobody can fix. -pub const CONTROL_VERSION: u32 = 7; +/// +/// v8 adds the six project verbs (`TabSetProject`, `ProjectCreate`, `Rename`, +/// `SetRoot`, `Move`, `Delete`) and the six `LayoutDelta` variants that ride +/// back on `ControlEvent::Layout`. This first shipped behind a `projects` +/// feature string instead, which was the v6 mistake in a new shape: a feature +/// can gate what a client *sends*, so a v7 server never saw a verb it could not +/// read, but nothing gates what a server *pushes* — a v7 client meeting a v8 +/// server that had grown a project took the delta, failed to decode the frame, +/// and lost the link exactly as described above. Only the number can turn that +/// pairing away at the handshake, which is why it is the number's job. +pub const CONTROL_VERSION: u32 = 8; const DIALECT_MARKER: &str = "speaks control v"; @@ -123,7 +133,9 @@ pub use crate::host::{Entry, MTime, Meta, Output, SearchHit}; pub use crate::core::shells::{DetectedShell, ShellInventory}; -pub use crate::core::machine::{Axis, LayoutDelta, Machine, PaneSeed, Side, Tab, TabId}; +pub use crate::core::machine::{ + Axis, LayoutDelta, Machine, PaneSeed, Project, ProjectId, Side, Tab, TabId, +}; pub use crate::core::session::WorkspaceId; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -268,6 +280,35 @@ pub enum ControlRequest { tab: TabId, group: Option, }, + TabSetProject { + workspace: WorkspaceId, + tab: TabId, + project: Option, + }, + ProjectCreate { + workspace: WorkspaceId, + at: Option, + project: Project, + }, + ProjectRename { + workspace: WorkspaceId, + project: ProjectId, + name: Option, + }, + ProjectSetRoot { + workspace: WorkspaceId, + project: ProjectId, + root: String, + }, + ProjectMove { + workspace: WorkspaceId, + project: ProjectId, + to: u64, + }, + ProjectDelete { + workspace: WorkspaceId, + project: ProjectId, + }, PaneSplit { workspace: WorkspaceId, pane: u64, @@ -365,6 +406,12 @@ impl ControlRequest { | TabRename { .. } | TabMove { .. } | TabSetGroup { .. } + | TabSetProject { .. } + | ProjectCreate { .. } + | ProjectRename { .. } + | ProjectSetRoot { .. } + | ProjectMove { .. } + | ProjectDelete { .. } | PaneSplit { .. } | PaneClose { .. } | PaneSetRatio { .. } diff --git a/crates/tty7-core/src/daemon/install/mod.rs b/crates/tty7-core/src/daemon/install/mod.rs index f17e520d..a23d2dff 100644 --- a/crates/tty7-core/src/daemon/install/mod.rs +++ b/crates/tty7-core/src/daemon/install/mod.rs @@ -1092,7 +1092,7 @@ impl<'a> Installer<'a> { /// server started some other way would be invisible to it. It is still the /// better of the two answers available there. Linux's `comm` is not an answer /// at all — the name truncated to 15 characters, one short of -/// `tty7-server-c7p6` — which is why the fallback stays a fallback and `/proc` +/// `tty7-server-c8p6` — which is why the fallback stays a fallback and `/proc` /// keeps first refusal. /// /// Neither arm reaches past the connecting user: `readlink` on another user's diff --git a/crates/tty7-core/src/host/server.rs b/crates/tty7-core/src/host/server.rs index cf90d3bc..5262b141 100644 --- a/crates/tty7-core/src/host/server.rs +++ b/crates/tty7-core/src/host/server.rs @@ -765,6 +765,64 @@ fn run_request( .tab_set_group(workspace, tab, group, conn.machine_origin)?; (ReplyOk::Unit, Vec::new()) } + ControlRequest::TabSetProject { + workspace, + tab, + project, + } => { + conn.machine()? + .tab_set_project(workspace, tab, project, conn.machine_origin)?; + (ReplyOk::Unit, Vec::new()) + } + ControlRequest::ProjectCreate { + workspace, + at, + project, + } => { + conn.machine()?.project_create( + workspace, + at.map(clamp_usize), + project, + conn.machine_origin, + )?; + (ReplyOk::Unit, Vec::new()) + } + ControlRequest::ProjectRename { + workspace, + project, + name, + } => { + conn.machine()? + .project_rename(workspace, project, name, conn.machine_origin)?; + (ReplyOk::Unit, Vec::new()) + } + ControlRequest::ProjectSetRoot { + workspace, + project, + root, + } => { + conn.machine()? + .project_set_root(workspace, project, root, conn.machine_origin)?; + (ReplyOk::Unit, Vec::new()) + } + ControlRequest::ProjectMove { + workspace, + project, + to, + } => { + conn.machine()?.project_move( + workspace, + project, + clamp_usize(to), + conn.machine_origin, + )?; + (ReplyOk::Unit, Vec::new()) + } + ControlRequest::ProjectDelete { workspace, project } => { + conn.machine()? + .project_delete(workspace, project, conn.machine_origin)?; + (ReplyOk::Unit, Vec::new()) + } ControlRequest::PaneSplit { workspace, pane, diff --git a/src/ui/app.rs b/src/ui/app.rs index 70a4c826..ff7269c0 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -425,6 +425,10 @@ pub struct Tab { pub(crate) diff_overlay: Option, pub(crate) code: Option>, pub(crate) sidebar_group: std::cell::RefCell>, + /// The project this tab was filed under, or `None` for a tab the derived + /// grouping below the projects section still owns. Only a user action + /// writes it — see `Tab::project` on the machine tree. + pub(crate) project: std::cell::Cell>, pub(crate) overlay_top: OverlayTop, /// Whether this tab's document fills the workspace or docks beside the /// terminal, once the tab has been told. `None` follows `document_layout` @@ -461,6 +465,7 @@ impl Tab { overlay_top: OverlayTop::default(), document_layout: None, sidebar_group: std::cell::RefCell::new(None), + project: std::cell::Cell::new(None), tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()), last_used: std::cell::Cell::new(0), } @@ -479,6 +484,7 @@ impl Tab { sidebar_group: std::cell::RefCell::new( tree.sidebar_group.clone().map(std::path::PathBuf::from), ), + project: std::cell::Cell::new(tree.project), tree_id: std::cell::Cell::new(tree.id), last_used: std::cell::Cell::new(0), } @@ -730,6 +736,13 @@ pub struct Tty7App { pub(crate) right_panel_visible: bool, pub(crate) right_panel_tab: RightPanelTab, pub(crate) sidebar_collapsed: bool, + /// The rail blocks that are folded shut. See [`crate::ui::tab_sidebar::SidebarFold`]. + pub(crate) sidebar_folded: + RefCell>, + /// The projects declared in this workspace, in sidebar order. The window's + /// own copy of `Workspace::projects` — `tree_sync` pushes edits made here + /// up to the machine and writes deltas from elsewhere back down. + pub(crate) projects: Vec, pub(crate) sidebar_scroll: gpui::ScrollHandle, pub(crate) reorder: Rc>>, /// The pane the pointer is over, so only that one offers its drag handle. @@ -776,6 +789,7 @@ pub struct Tty7App { window_bounds: Bounds, pub(crate) workspace: WorkspaceId, pub(crate) workspace_rename: Option, + pub(crate) project_rename: Option, window_title: std::cell::RefCell, pub(crate) connect: Option, pub(crate) switcher: Option, @@ -1199,6 +1213,10 @@ impl Tty7App { apply_theme(Some(window), cx); set_menus(cx); let mut startup_error: Option = None; + let projects = session + .as_ref() + .map(|s| s.projects.clone()) + .unwrap_or_default(); let (tabs, active) = match session { None => match new_terminal( pane_ws.clone(), @@ -1257,6 +1275,7 @@ impl Tty7App { let mut app = Self { tabs, active, + projects, tab_use_seq: std::cell::Cell::new(0), pending_tab: None, font_size, @@ -1324,6 +1343,7 @@ impl Tty7App { right_panel_visible, right_panel_tab, sidebar_collapsed, + sidebar_folded: RefCell::new(std::collections::HashSet::new()), sidebar_scroll: gpui::ScrollHandle::new(), reorder: Rc::new(RefCell::new(None)), pane_hover: Rc::new(Cell::new(None)), @@ -1343,6 +1363,7 @@ impl Tty7App { window_bounds: window_bounds_to_remember(window), workspace, workspace_rename: None, + project_rename: None, window_title: std::cell::RefCell::new(String::new()), connect: None, switcher: None, @@ -1600,6 +1621,10 @@ impl Tty7App { self.refresh_shells(cx); let font_size = self.font_size; let pane_ws = self.window_workspace(cx); + // Replaced, not merged: this is how a window changes which workspace + // it is showing, and the projects it was showing belong to the one it + // is leaving. + self.projects = session.projects.clone(); let (tabs, active, dropped) = tabs_from_session( pane_ws.as_ref(), self.workspace, @@ -1654,6 +1679,7 @@ impl Tty7App { overlay_top: OverlayTop::default(), document_layout: None, sidebar_group: std::cell::RefCell::new(st.sidebar_group), + project: std::cell::Cell::new(st.project), tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()), last_used: std::cell::Cell::new(0), }, @@ -3241,7 +3267,19 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) { - self.new_tab_with_cwd(Some(cwd), None, window, cx); + self.new_tab_with_cwd(Some(cwd), None, None, window, cx); + } + + /// A new tab in `cwd`, filed under `project` from the moment it exists so + /// it never flickers through the derived grouping on its way there. + pub(crate) fn new_tab_at_in( + &mut self, + cwd: std::path::PathBuf, + project: Option, + window: &mut Window, + cx: &mut Context, + ) { + self.new_tab_with_cwd(Some(cwd), None, project, window, cx); } pub(crate) fn new_tab_with_shell( @@ -3255,7 +3293,7 @@ impl Tty7App { .focused_or_first(window, cx) .and_then(|leaf| leaf.read(cx).spawnable_cwd()) }); - self.new_tab_with_cwd(cwd, shell, window, cx); + self.new_tab_with_cwd(cwd, shell, None, window, cx); } /// A shell taken out of the new-tab menu, wherever the ⌥ key said to put @@ -3279,6 +3317,7 @@ impl Tty7App { &mut self, cwd: Option, shell: Option, + project: Option, window: &mut Window, cx: &mut Context, ) { @@ -3317,6 +3356,7 @@ impl Tty7App { if let Some(group) = group { *new_tab.sidebar_group.borrow_mut() = group; } + new_tab.project.set(project); self.tabs.insert(insert_at, new_tab); self.active = insert_at; self.focus_active(window, cx); @@ -7720,6 +7760,7 @@ fn tab_to_session(tab: &Tab, cx: &App) -> SessionTab { name: tab.name.clone(), pane: pane_to_session(&tab.pane, cx), sidebar_group: tab.sidebar_group.borrow().clone(), + project: tab.project.get(), tree_id: None, } } @@ -7921,6 +7962,7 @@ fn tabs_from_session( overlay_top: OverlayTop::default(), document_layout: None, sidebar_group: std::cell::RefCell::new(st.sidebar_group.clone()), + project: std::cell::Cell::new(st.project), tree_id: std::cell::Cell::new( st.tree_id .unwrap_or_else(tty7_core::core::machine::TabId::new), @@ -9540,6 +9582,7 @@ mod ssh_rebuild_gpui_tests { id: app.tabs[0].tree_id.get(), name: None, sidebar_group: None, + project: None, root: PaneNode::Leaf { pane: 1 }, }; app.apply_layout_delta( diff --git a/src/ui/home.rs b/src/ui/home.rs index f7df385b..7d9c9051 100644 --- a/src/ui/home.rs +++ b/src/ui/home.rs @@ -338,6 +338,7 @@ mod tests { name: Some("build".into()), tree_id: None, sidebar_group: None, + project: None, pane: leaf(Some("/work/getty")), }; assert_eq!(closed_tab_label(&tab).as_deref(), Some("build")); @@ -349,6 +350,7 @@ mod tests { name: None, tree_id: None, sidebar_group: None, + project: None, pane: leaf(Some("/work/getty")), }; assert_eq!(closed_tab_label(&tab).as_deref(), Some("getty")); @@ -357,6 +359,7 @@ mod tests { name: Some(" ".into()), tree_id: None, sidebar_group: None, + project: None, pane: leaf(Some("/work/getty")), }; assert_eq!(closed_tab_label(&tab).as_deref(), Some("getty")); @@ -368,6 +371,7 @@ mod tests { name: None, tree_id: None, sidebar_group: None, + project: None, pane: SessionPane::Split { axis: crate::core::session::SessionAxis::Horizontal, ratio: 0.5, @@ -384,6 +388,7 @@ mod tests { name: None, tree_id: None, sidebar_group: None, + project: None, pane: leaf(None), }; assert_eq!(closed_tab_label(&unnamed), None); @@ -391,6 +396,7 @@ mod tests { name: None, tree_id: None, sidebar_group: None, + project: None, pane: leaf(Some("/")), }; assert_eq!(closed_tab_label(&root), None); @@ -402,6 +408,7 @@ mod tests { name: Some("a".repeat(40)), tree_id: None, sidebar_group: None, + project: None, pane: leaf(None), }; let label = closed_tab_label(&tab).unwrap(); diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index b1cf3fc6..15502b81 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1779,6 +1779,19 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::TabUnnamedShell => "Shell {n}", L10nKey::ShellDefault => "default", L10nKey::SidebarScratchGroup => "Scratch", + L10nKey::SidebarProjectsHeading => "Projects", + L10nKey::SidebarTabsHeading => "Tabs", + L10nKey::ProjectNewTooltip => "Declare a folder a project", + L10nKey::ProjectRename => "Rename project…", + L10nKey::ProjectSetFolder => "Change folder…", + L10nKey::ProjectDelete => "Remove project", + L10nKey::ProjectNewTab => "New tab here", + L10nKey::ProjectEmpty => "open a tab here", + L10nKey::TabProjectAddTo => "Move to project", + L10nKey::TabProjectRemove => "Remove from project", + L10nKey::TabProjectFromFolder => "New project from this folder", + L10nKey::ProjectNoFolder => "That tab has no folder to make a project from", + L10nKey::ProjectLimitReached => "This workspace already holds {max} projects", L10nKey::TabContextCloseTab => "Close Tab", L10nKey::TabContextCloseTabsBelow => "Close Tabs Below", L10nKey::AppAgentHooksOpFailed => "Failed: {error}", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index d9e4cf07..d144c560 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1850,6 +1850,21 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::TabUnnamedShell => "シェル {n}", L10nKey::ShellDefault => "デフォルト", L10nKey::SidebarScratchGroup => "スクラッチ", + L10nKey::SidebarProjectsHeading => "プロジェクト", + L10nKey::SidebarTabsHeading => "タブ", + L10nKey::ProjectNewTooltip => "フォルダをプロジェクトにする", + L10nKey::ProjectRename => "プロジェクト名を変更…", + L10nKey::ProjectSetFolder => "フォルダを変更…", + L10nKey::ProjectDelete => "プロジェクトを削除", + L10nKey::ProjectNewTab => "ここで新規タブ", + L10nKey::ProjectEmpty => "ここでタブを開く", + L10nKey::TabProjectAddTo => "プロジェクトへ移動", + L10nKey::TabProjectRemove => "プロジェクトから外す", + L10nKey::TabProjectFromFolder => "このフォルダで新規プロジェクト", + L10nKey::ProjectNoFolder => "このタブにはプロジェクトにできるフォルダがありません", + L10nKey::ProjectLimitReached => { + "このワークスペースには既に {max} 個のプロジェクトがあります" + } L10nKey::TabContextCloseTab => "タブを閉じる", L10nKey::TabContextCloseTabsBelow => "下のタブを閉じる", L10nKey::AppAgentHooksOpFailed => "失敗: {error}", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 62e8c063..97a6cc2e 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -1004,6 +1004,19 @@ l10n_keys! { TabUnnamedShell, ShellDefault, SidebarScratchGroup, + SidebarProjectsHeading, + SidebarTabsHeading, + ProjectNewTooltip, + ProjectRename, + ProjectSetFolder, + ProjectDelete, + ProjectNewTab, + ProjectEmpty, + TabProjectAddTo, + TabProjectRemove, + TabProjectFromFolder, + ProjectNoFolder, + ProjectLimitReached, TabContextCloseTab, TabContextCloseTabsBelow, TabContextMarkUnread, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index e942e5b7..a5f2702f 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1689,6 +1689,19 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::TabUnnamedShell => "终端 {n}", L10nKey::ShellDefault => "默认", L10nKey::SidebarScratchGroup => "草稿", + L10nKey::SidebarProjectsHeading => "项目", + L10nKey::SidebarTabsHeading => "标签页", + L10nKey::ProjectNewTooltip => "把一个目录声明为项目", + L10nKey::ProjectRename => "重命名项目…", + L10nKey::ProjectSetFolder => "更改目录…", + L10nKey::ProjectDelete => "移除项目", + L10nKey::ProjectNewTab => "在此新建标签页", + L10nKey::ProjectEmpty => "在此打开一个标签页", + L10nKey::TabProjectAddTo => "移入项目", + L10nKey::TabProjectRemove => "移出项目", + L10nKey::TabProjectFromFolder => "以此目录新建项目", + L10nKey::ProjectNoFolder => "这个标签页没有可用来建项目的目录", + L10nKey::ProjectLimitReached => "这个工作区已经有 {max} 个项目了", L10nKey::TabContextCloseTab => "关闭标签页", L10nKey::TabContextCloseTabsBelow => "关闭下方标签页", L10nKey::AppAgentHooksOpFailed => "失败:{error}", diff --git a/src/ui/machine_mirror.rs b/src/ui/machine_mirror.rs index 6e764e46..3b80190c 100644 --- a/src/ui/machine_mirror.rs +++ b/src/ui/machine_mirror.rs @@ -170,6 +170,22 @@ impl MachineMirrors { }); } + /// The projects a window just pushed, recorded for the same reason the + /// tabs above are: the deltas that carried them are not sent back to the + /// window that raised them, so this mirror would never hear about them. + pub fn note_synced_projects( + cx: &mut App, + host: HostId, + machine_ws: WorkspaceId, + projects: Vec, + ) { + Self::write(cx, host, move |machine| { + if let Some(ws) = machine.workspaces.iter_mut().find(|w| w.id == machine_ws) { + ws.projects = projects.clone(); + } + }); + } + /// Records for the panes this window itself seeded into the machine. /// /// A window is left out of the deltas its own ops raise, and `TabCreated` @@ -306,6 +322,51 @@ fn apply(machine: &mut Machine, workspace: WorkspaceId, delta: &LayoutDelta) -> t.sidebar_group = group.clone(); true } + LayoutDelta::TabProjectChanged { tab, project } => { + let Some(t) = ws.tabs.iter_mut().find(|t| t.id == *tab) else { + return false; + }; + t.project = *project; + true + } + LayoutDelta::ProjectCreated { at, project } => { + ws.projects.retain(|p| p.id != project.id); + let at = (*at).min(ws.projects.len()); + ws.projects.insert(at, project.clone()); + true + } + LayoutDelta::ProjectRenamed { project, name } => { + let Some(p) = ws.projects.iter_mut().find(|p| p.id == *project) else { + return false; + }; + p.name = name.clone(); + true + } + LayoutDelta::ProjectRerooted { project, root } => { + let Some(p) = ws.projects.iter_mut().find(|p| p.id == *project) else { + return false; + }; + p.root = root.clone(); + true + } + LayoutDelta::ProjectMoved { project, to } => { + let Some(from) = ws.projects.iter().position(|p| p.id == *project) else { + return false; + }; + let moved = ws.projects.remove(from); + ws.projects.insert((*to).min(ws.projects.len()), moved); + true + } + LayoutDelta::ProjectDeleted { project } => { + let before = ws.projects.len(); + ws.projects.retain(|p| p.id != *project); + for tab in &mut ws.tabs { + if tab.project == Some(*project) { + tab.project = None; + } + } + ws.projects.len() != before + } LayoutDelta::TabMoved { tab, to } => { let Some(from) = ws.tabs.iter().position(|t| t.id == *tab) else { return false; @@ -919,6 +980,7 @@ mod tests { id: tab_id, name: None, sidebar_group: None, + project: None, root: PaneNode::Split { axis: Axis::Vertical, ratio: 0.5, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 31f360c8..ead68a9d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -25,6 +25,7 @@ pub mod pending_pane; pub mod perf; pub mod prefill; pub mod presets; +pub mod projects; pub mod remote_connect; pub mod remote_workspace; pub mod reorder; diff --git a/src/ui/projects.rs b/src/ui/projects.rs new file mode 100644 index 00000000..6062a0c2 --- /dev/null +++ b/src/ui/projects.rs @@ -0,0 +1,388 @@ +//! Projects: the declared half of the sidebar. +//! +//! A repo group in the sidebar is derived — its identity is a path, it appears +//! when a tab lands in it and vanishes with its last tab. A project is +//! declared: it has an id, someone made it on purpose, it carries a name that +//! owes nothing to its directory, and it stays when the last tab in it closes. +//! +//! Nothing here probes anything. A tab joins a project because someone said +//! so, and leaves it the same way; the filing-by-cwd that used to be the only +//! grouping there was still runs, one section further down, over the tabs no +//! project has claimed. + +use std::path::PathBuf; + +use gpui::{Context, Entity, Subscription, Window}; +use gpui_component::WindowExt as _; +use gpui_component::input::{InputEvent, InputState}; +use tty7_core::core::machine::{MAX_PROJECTS, Project, ProjectId}; + +use crate::core::session::WorkspaceStore; +use crate::ui::app::Tty7App; +use crate::ui::i18n::{L10nKey, t, t_fmt}; + +/// A project header that has turned into a text box. +pub(crate) struct ProjectRename { + pub(crate) project: ProjectId, + pub(crate) input: Entity, + _subs: Vec, +} + +impl Tty7App { + pub(crate) fn project(&self, id: ProjectId) -> Option<&Project> { + self.projects.iter().find(|p| p.id == id) + } + + /// The folder a tab would make a project out of: its repo home when the + /// probe found one, otherwise whatever directory it is sitting in. + pub(crate) fn tab_project_root( + &self, + index: usize, + window: &Window, + cx: &gpui::App, + ) -> Option { + let tab = self.tabs.get(index)?; + if let Some(group) = tab.sidebar_group.borrow().clone() { + return Some(group); + } + tab.pane + .focused_or_first(window, cx) + .and_then(|leaf| leaf.read(cx).spawnable_cwd()) + } + + /// Declares `root` a project, or hands back the one already on it. + /// + /// `None` means the workspace is full. The machine refuses past + /// [`MAX_PROJECTS`] too, and a refusal there resynchronizes — which would + /// re-push the project this window kept and be refused again, so the limit + /// has to be held on this side of the wire as well. + pub(crate) fn declare_project( + &mut self, + root: PathBuf, + cx: &mut Context, + ) -> Option { + let root = root.to_string_lossy().into_owned(); + // One project per directory: declaring the same folder twice would put + // two headers on screen that mean the same thing, and the second one + // could never be told apart from the first. + if let Some(existing) = self.projects.iter().find(|p| p.root == root) { + return Some(existing.id); + } + if self.projects.len() >= MAX_PROJECTS { + return None; + } + let project = Project::at(root); + let id = project.id; + self.projects.push(project); + self.save_session(cx); + cx.notify(); + Some(id) + } + + /// The `+` on the projects heading: pick a folder and declare it. + /// + /// The native panel browses the machine this window runs on, which on a + /// remote workspace is the wrong machine — so there the folder comes from + /// the tab that is open instead. A button that opens a panel onto paths + /// the workspace cannot reach would be worse than one that guesses. + pub(crate) fn new_project(&mut self, window: &mut Window, cx: &mut Context) { + if self.workspace_is_remote(cx) { + self.project_from_tab(self.active, window, cx); + return; + } + // Checked before the panel opens rather than after it closes: being + // told the workspace is full is one thing, being told it after picking + // a folder is another. + if !self.room_for_a_project(window, cx) { + return; + } + self.pick_folder(cx, |this, path, cx| { + this.declare_project(path, cx); + }); + } + + /// Whether another project fits, saying so on the way out if not. + fn room_for_a_project(&self, window: &mut Window, cx: &mut Context) -> bool { + if self.projects.len() < MAX_PROJECTS { + return true; + } + window.push_notification( + t_fmt( + L10nKey::ProjectLimitReached, + &[("max", &MAX_PROJECTS.to_string())], + ), + cx, + ); + false + } + + pub(crate) fn pick_project_root(&mut self, project: ProjectId, cx: &mut Context) { + self.pick_folder(cx, move |this, path, cx| { + this.set_project_root(project, path, cx); + }); + } + + fn pick_folder( + &mut self, + cx: &mut Context, + then: impl FnOnce(&mut Self, PathBuf, &mut Context) + 'static, + ) { + debug_assert!( + !self.workspace_is_remote(cx), + "the folder panel browses this machine; a remote workspace has to \ + reach its folders another way" + ); + let rx = cx.prompt_for_paths(gpui::PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: None, + }); + cx.spawn(async move |this, cx| { + let Ok(Ok(Some(paths))) = rx.await else { + return; + }; + let Some(path) = paths.into_iter().next() else { + return; + }; + let _ = this.update(cx, |this, cx| then(this, path, cx)); + }) + .detach(); + } + + pub(crate) fn workspace_is_remote(&self, cx: &gpui::App) -> bool { + WorkspaceStore::all(cx) + .get(self.workspace) + .is_some_and(|w| w.is_remote()) + } + + pub(crate) fn set_project_root( + &mut self, + project: ProjectId, + root: PathBuf, + cx: &mut Context, + ) { + let root = root.to_string_lossy().into_owned(); + // The same one-project-per-directory rule `declare_project` holds on + // the way in. Pointing one project at another's folder would reach the + // state that rule exists to keep out — two headers that mean the same + // thing — by the back door. + if self + .projects + .iter() + .any(|p| p.id != project && p.root == root) + { + return; + } + let Some(p) = self.projects.iter_mut().find(|p| p.id == project) else { + return; + }; + if p.root == root { + return; + } + p.root = root; + self.save_session(cx); + cx.notify(); + } + + /// Deletes a project. Its tabs stay open and fall back to the derived + /// grouping — deleting a project says the grouping is over, not the work. + pub(crate) fn delete_project(&mut self, project: ProjectId, cx: &mut Context) { + let before = self.projects.len(); + self.projects.retain(|p| p.id != project); + if self.projects.len() == before { + return; + } + for tab in &self.tabs { + if tab.project.get() == Some(project) { + tab.project.set(None); + } + } + self.save_session(cx); + cx.notify(); + } + + pub(crate) fn set_tab_project( + &mut self, + index: usize, + project: Option, + cx: &mut Context, + ) { + let Some(tab) = self.tabs.get(index) else { + return; + }; + if tab.project.get() == project { + return; + } + tab.project.set(project); + self.save_session(cx); + cx.notify(); + } + + /// Declares the tab's folder a project and files the tab under it in one + /// act — the shortest path from "I am working here" to a named project. + pub(crate) fn project_from_tab( + &mut self, + index: usize, + window: &mut Window, + cx: &mut Context, + ) { + let Some(root) = self.tab_project_root(index, window, cx) else { + window.push_notification(t(L10nKey::ProjectNoFolder), cx); + return; + }; + if !self + .projects + .iter() + .any(|p| p.root == root.to_string_lossy()) + && !self.room_for_a_project(window, cx) + { + return; + } + let Some(id) = self.declare_project(root, cx) else { + return; + }; + self.set_tab_project(index, Some(id), cx); + self.start_project_rename(id, window, cx); + } + + pub(crate) fn new_tab_in_project( + &mut self, + project: ProjectId, + window: &mut Window, + cx: &mut Context, + ) { + let Some(root) = self.project(project).map(|p| PathBuf::from(&p.root)) else { + return; + }; + self.new_tab_at_in(root, Some(project), window, cx); + } + + pub(crate) fn start_project_rename( + &mut self, + project: ProjectId, + window: &mut Window, + cx: &mut Context, + ) { + // A box already open on another project is committed, not dropped. + // Dropping it would take the subscription with it, so the typing that + // was sitting in it would never reach `commit_project_rename` — the + // rename would be silently thrown away by opening a second one. + if self + .project_rename + .as_ref() + .is_some_and(|r| r.project != project) + { + self.commit_project_rename(window, cx); + } + let Some(p) = self.project(project) else { + return; + }; + // The box opens on the name the header is showing, derived title and + // all, so renaming an unnamed project starts from what it reads as + // rather than from nothing. + let current = p + .name + .clone() + .unwrap_or_else(|| derived_project_name(&self.projects, project).unwrap_or_default()); + let input = Self::rename_box(current, window, cx); + let subs = vec![cx.subscribe_in( + &input, + window, + |this, _input, ev: &InputEvent, window, cx| match ev { + InputEvent::PressEnter { .. } | InputEvent::Blur => { + this.commit_project_rename(window, cx) + } + _ => {} + }, + )]; + self.project_rename = Some(ProjectRename { + project, + input, + _subs: subs, + }); + cx.notify(); + } + + pub(crate) fn commit_project_rename(&mut self, window: &mut Window, cx: &mut Context) { + let Some(rename) = self.project_rename.take() else { + return; + }; + let value = rename.input.read(cx).value().trim().to_string(); + // Typing the derived title back is not a rename: it would pin a name + // that is already what the header says, and the project would then + // stop following its folder for no visible reason. + let derived = derived_project_name(&self.projects, rename.project); + let name = match value { + v if v.is_empty() => None, + v if Some(&v) == derived.as_ref() => None, + v => Some(v), + }; + if let Some(p) = self.projects.iter_mut().find(|p| p.id == rename.project) + && p.name != name + { + p.name = name; + self.save_session(cx); + } + self.focus_active(window, cx); + cx.notify(); + } +} + +/// What each project's header reads when nobody has named it: the last +/// component of its root, widened a component at a time until no two projects +/// read the same. The same walk-up the derived group headers do, over the +/// project roots instead of the repo roots. +pub(crate) fn project_names(projects: &[Project]) -> Vec { + let roots: Vec = projects.iter().map(|p| PathBuf::from(&p.root)).collect(); + let refs: Vec<&PathBuf> = roots.iter().collect(); + let derived = crate::ui::tab_sidebar::group_names(&refs); + projects + .iter() + .zip(derived) + .map(|(p, fallback)| match p.name.as_deref().map(str::trim) { + Some(name) if !name.is_empty() => name.to_string(), + _ => fallback, + }) + .collect() +} + +fn derived_project_name(projects: &[Project], project: ProjectId) -> Option { + let at = projects.iter().position(|p| p.id == project)?; + let roots: Vec = projects.iter().map(|p| PathBuf::from(&p.root)).collect(); + let refs: Vec<&PathBuf> = roots.iter().collect(); + crate::ui::tab_sidebar::group_names(&refs) + .into_iter() + .nth(at) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn project(root: &str, name: Option<&str>) -> Project { + Project { + name: name.map(str::to_string), + ..Project::at(root) + } + } + + #[test] + fn an_unnamed_project_reads_as_its_folder_and_disambiguates() { + let projects = vec![ + project("/home/u/work/app", None), + project("/home/u/fork/app", None), + project("/home/u/tty7", Some("套利研究")), + ]; + assert_eq!( + project_names(&projects), + vec!["work/app", "fork/app", "套利研究"] + ); + } + + #[test] + fn a_blank_name_falls_back_rather_than_rendering_nothing() { + let projects = vec![project("/w/repo", Some(" "))]; + assert_eq!(project_names(&projects), vec!["repo"]); + } +} diff --git a/src/ui/reorder.rs b/src/ui/reorder.rs index 134fdda1..ad1da62f 100644 --- a/src/ui/reorder.rs +++ b/src/ui/reorder.rs @@ -1,6 +1,5 @@ use gpui::{Axis, Bounds, Pixels, Point, Styled, px}; use std::cell::{Cell, RefCell}; -use std::path::PathBuf; use std::rc::Rc; use tty7_core::core::machine::TabId; @@ -95,7 +94,7 @@ pub(crate) fn suspend(state: &ReorderState, yes: bool) { #[derive(Clone, PartialEq, Eq, Debug)] pub(crate) enum Surface { Strip, - SidebarRows(Option), + SidebarRows(crate::ui::tab_sidebar::SectionKey), SidebarGroups, } diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 6324133e..57863eae 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -12,6 +12,8 @@ use std::rc::Rc; use std::path::{Path, PathBuf}; +use tty7_core::core::machine::ProjectId; + use crate::core::config::{Config, SidebarGrouping}; use crate::terminal::git_status::GitStatusCache; use crate::ui::app::{TITLE_BAR_HEIGHT, Tty7App}; @@ -161,20 +163,16 @@ impl Tty7App { .gap_0p5(); let keys: Rc>> = Rc::new(self.sidebar_group_keys(cx)); - let sections = sidebar_sections(&keys); + let members: Vec> = self.tabs.iter().map(|t| t.project.get()).collect(); + let sections = Rc::new(sidebar_sections(&keys, &members, &self.named_projects())); // ⌘N runs ActivateTabN, which goes through `activate_visual` — the // Nth row as the sidebar lays it out, not the Nth tab in `self.tabs`. // The badge has to be read off the same order or it names a chord that // opens a different tab, so take it from `visual_tab_order` rather than // flattening `sections` a second time here. - let badge_pos: Vec = { - let mut pos = vec![0usize; self.tabs.len()]; - for (n, i) in self.visual_tab_order(cx).into_iter().enumerate() { - pos[i] = n; - } - pos - }; + // + let badge_pos = badge_positions(&self.visual_tab_order(cx), self.tabs.len()); // The row shows an elided title and a branch; the filter used to read // only the elided title, so typing the branch you can see, or the part @@ -226,8 +224,13 @@ impl Tty7App { }; let rem = window.rem_size().as_f32(); let rendered = |ix: &usize| !visible_by_section[*ix].is_empty(); + // A project is on screen because someone declared it, not because a + // tab landed in it, so an empty one still draws its header — that row + // is where its first tab comes from. Under a live search it drops out + // like anything else that matched nothing. + let project_rendered = |ix: &usize| query.is_empty() || !visible_by_section[*ix].is_empty(); let repo_slots: Vec = (0..sections.len()) - .filter(|&ix| sections[ix].key.is_some()) + .filter(|&ix| sections[ix].key.repo().is_some()) .filter(rendered) .collect(); let repo_groups = repo_slots.len(); @@ -235,14 +238,11 @@ impl Tty7App { Rc::new(RefCell::new(vec![Bounds::default(); repo_groups])); let group_preview = reorder::preview(&self.reorder, &Surface::SidebarGroups, repo_groups, pointer); - let repo_roots: Vec = repo_slots - .iter() - .filter_map(|&ix| sections[ix].key.clone()) - .collect(); let slot_display: Vec = match &group_preview { Some(p) => { - if let (Some(from), Some(to)) = (repo_roots.get(p.from), repo_roots.get(p.target)) - && let Some(order) = regrouped_order(&keys, from, to) + if let (Some(&from), Some(&to)) = (repo_slots.get(p.from), repo_slots.get(p.target)) + && let Some(order) = + regrouped_order(§ions, §ions[from].key, §ions[to].key) { reorder::set_pending(&self.reorder, &Surface::SidebarGroups, order); } @@ -250,22 +250,72 @@ impl Tty7App { } None => (0..repo_groups).collect(), }; - let mut blocks: Vec<(Option, usize)> = slot_display - .into_iter() - .map(|slot| (Some(slot), repo_slots[slot])) + let mut blocks: Vec<(Option, usize)> = (0..sections.len()) + .filter(|&ix| sections[ix].key.project().is_some()) + .filter(project_rendered) + .map(|ix| (None, ix)) .collect(); + blocks.extend( + slot_display + .into_iter() + .map(|slot| (Some(slot), repo_slots[slot])), + ); blocks.extend( (0..sections.len()) - .filter(|&ix| sections[ix].key.is_none()) + .filter(|&ix| sections[ix].key == SectionKey::Scratch) .filter(rendered) .map(|ix| (None, ix)), ); - for (group_slot, group_ix) in blocks { + let projects_shown = blocks + .iter() + .filter(|(_, ix)| sections[*ix].key.project().is_some()) + .count(); + // The two headings are what say which half of the rail you are + // looking at: above the second one everything was declared, below it + // everything was derived. Both are drawn whether or not anything is + // under them — an empty PROJECTS is the invitation to declare one, and + // TABS carries the new-tab button. + // + // A live search hides them — the query is asking about tabs, and + // chrome that always matches would keep "nothing matches" from ever + // showing. + let headings = query.is_empty(); + let projects_folded = self.is_folded(&SidebarFold::Projects); + let tabs_folded = self.is_folded(&SidebarFold::Tabs); + if headings { + list = list.child(self.projects_heading(cx)); + } + + let block_count = blocks.len(); + for (block_at, (group_slot, group_ix)) in blocks.into_iter().enumerate() { + if headings && block_at == projects_shown { + list = list.child(self.tabs_heading(projects_shown > 0 && !projects_folded, cx)); + } let section = §ions[group_ix]; let group_key = section.key.clone(); + let project = group_key.project(); + // A folded heading keeps its own half off the rail entirely, + // headers and all. Only a heading can do that; folding one block + // leaves its header behind, because that header is the way back. + let half_folded = match project { + Some(_) => projects_folded, + None => tabs_folded, + }; + if headings && half_folded { + continue; + } + let folded = self.is_folded(&SidebarFold::Section(group_key.clone())); + // A folded block draws its header and nothing else, so the rows + // are never built — and never write a rectangle a pane could be + // dropped onto, which is what `sidebar_slots` being blanked every + // frame is for. let mut rows: Vec>> = Vec::new(); - let visible = visible_by_section[group_ix].clone(); + let visible = if folded { + Vec::new() + } else { + visible_by_section[group_ix].clone() + }; let visible_tabs: Vec = visible.clone(); let row_slots: Rc>>> = Rc::new(RefCell::new(vec![Bounds::default(); visible.len()])); @@ -291,7 +341,10 @@ impl Tty7App { Some((view.host_id(), cwd)) }), ); - let badge_extra = if show_badges && badge_pos < 9 { + // The number this row actually wears, or `None` for no badge: + // hints turned off, past ⌘9, or a row the chord order left out. + let badge_n = badge_pos.filter(|_| show_badges).filter(|&n| n < 9); + let badge_extra = if badge_n.is_some() { row_metrics::BADGE + row_metrics::GAP } else { 0. @@ -724,7 +777,7 @@ impl Tty7App { cx, )) .child(label_region) - .when(show_badges && badge_pos < 9, |row| { + .when_some(badge_n, |row, n| { row.child( div() .flex_shrink_0() @@ -739,10 +792,10 @@ impl Tty7App { } else { cx.theme().muted_foreground }) - .child(tab_badge_label(badge_pos)), + .child(tab_badge_label(n)), ) }) - .when(!(show_badges && badge_pos < 9), |row| { + .when(badge_n.is_none(), |row| { let backing: gpui::Hsla = if is_active { gpui::rgb(sf.selected).into() } else { @@ -797,14 +850,14 @@ impl Tty7App { })); } - if rows.is_empty() { + if rows.is_empty() && project.is_none() && !folded { continue; } let row_display: Vec = match &row_preview { Some(p) => { if let Some(order) = - reordered_rows(&keys, &group_key, &visible_tabs, p.from, p.target) + reordered_rows(§ions, &group_key, &visible_tabs, p.from, p.target) { reorder::set_pending( &self.reorder, @@ -816,7 +869,11 @@ impl Tty7App { } None => (0..rows.len()).collect(), }; - let row_count = rows.len(); + let row_count = if folded { + visible_by_section[group_ix].len() + } else { + rows.len() + }; let mut rows: Vec>>> = rows.into_iter().map(Some).collect(); let rows: Vec = row_display @@ -852,19 +909,46 @@ impl Tty7App { .into_any_element(), }) .collect(); - let header = section.name.clone().map(|name| { - let label: SharedString = name.to_uppercase().into(); - h_flex() + // The header a project is being renamed under is a text box, so + // the name is edited where it is read rather than in a dialog + // somewhere else. + let renaming = project.and_then(|id| { + self.project_rename + .as_ref() + .filter(|r| r.project == id) + .map(|r| r.input.clone()) + }); + let header: Option = section.name.clone().map(|name| { + // Left in the case it came in. These names are directories + // (`tty7`, `025/1inch`) and translated words, and capitalising + // them made a Latin one shout while a Chinese one — where + // there is no case to raise — stayed exactly as it was: one + // style, two different readings. Sentence case also puts a + // block header a clear step below the heading above it, which + // is the whole of the hierarchy in this rail. + let label: SharedString = name.into(); + let head = h_flex() .id(("sidebar-group", group_ix)) + .group(SharedString::from(format!("sidebar-head-{group_ix}"))) .w_full() .items_center() - .gap_1p5() - .pl_2() + .gap_1() + .pl_1p5() .pr_1p5() .pt_1p5() .pb_0p5() - .text_size(px(11.)) - .text_color(cx.theme().muted_foreground) + .text_size(px(12.)) + .text_color(cx.theme().sidebar_foreground) + .cursor_pointer() + .on_click({ + let key = group_key.clone(); + cx.listener(move |this, _, _window, cx| { + cx.stop_propagation(); + this.toggle_fold(SidebarFold::Section(key.clone())); + cx.notify(); + }) + }) + .child(fold_chevron(folded, 12.)) .when_some(group_slot, |header, slot| { crate::ui::reorder::cursor_grab(header).on_drag(DragGroup, { let state = self.reorder.clone(); @@ -883,20 +967,99 @@ impl Tty7App { } }) }) - .child( - div() + .when(project.is_some(), |head| { + head.child( + Icon::empty() + .path("icons/folder.svg") + .size(px(12.)) + .flex_shrink_0() + .text_color(cx.theme().muted_foreground), + ) + }) + .child(match renaming { + Some(input) => div() + .flex_1() + .min_w_0() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child(Input::new(&input).appearance(false).xsmall()) + .into_any_element(), + None => div() .flex_shrink(1.) .min_w_0() .truncate() - .font_weight(FontWeight::SEMIBOLD) - .child(label), - ) + .font_weight(FontWeight::MEDIUM) + .child(label) + .into_any_element(), + }) .child( div() .flex_shrink_0() - .text_color(cx.theme().muted_foreground.opacity(0.7)) + .text_size(px(11.)) + .text_color(cx.theme().muted_foreground) .child(row_count.to_string()), ) + .when_some(project, |head, id| { + head.child(div().flex_1()).child( + div() + .flex_shrink_0() + .opacity(0.) + .group_hover( + SharedString::from(format!("sidebar-head-{group_ix}")), + |s| s.opacity(1.), + ) + .child( + crate::ui::tab_strip::hit_target( + Button::new(("project-new-tab", group_ix)) + .icon(IconName::Plus) + .ghost() + .xsmall(), + ) + .tooltip(t(L10nKey::ProjectNewTab)) + .on_click(cx.listener( + move |this, _, window, cx| { + cx.stop_propagation(); + this.new_tab_in_project(id, window, cx); + }, + )), + ), + ) + }); + match project { + Some(id) => { + let menu_app = cx.entity().downgrade(); + head.context_menu(move |menu, window, cx| { + Tty7App::project_context_menu(menu, id, &menu_app, window, cx) + }) + .into_any_element() + } + None => head.into_any_element(), + } + }); + + // A project with nothing in it says so, and the line that says it + // is also the way to put something in it. + let empty_hint = (project.is_some() && rows.is_empty() && !folded).then(|| { + let id = project.expect("checked just above"); + h_flex() + .id(("project-empty", group_ix)) + .w_full() + .items_center() + .gap_1p5() + .pl_2() + .pr_2() + .py_1() + .rounded_lg() + .cursor_pointer() + .text_xs() + .text_color(cx.theme().muted_foreground.opacity(0.8)) + .hover(|s| s.bg(gpui::rgb(sf.hover))) + .child(Icon::new(IconName::Plus).size(px(11.)).flex_shrink_0()) + .child(div().truncate().child(t(L10nKey::ProjectEmpty))) + .on_click(cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + this.new_tab_in_project(id, window, cx); + })) + .into_any_element() }); let block = v_flex() @@ -910,6 +1073,7 @@ impl Tty7App { ) .children(header) .children(rows) + .children(empty_hint) .when_some(group_slot, |block, slot| { block.child( canvas( @@ -928,7 +1092,9 @@ impl Tty7App { ) }); - any_rows = true; + // What the search found, not what was drawn: a block folded shut + // still matched, and "nothing matches" would be a lie. + any_rows |= !visible_by_section[group_ix].is_empty(); list = list.child(match (&group_preview, group_slot) { (Some(p), Some(slot)) if p.from == slot => { deferred(block.relative().top(p.held)).into_any_element() @@ -951,6 +1117,13 @@ impl Tty7App { }); } + // The derived half can be empty — every tab filed under a project — + // and its heading still has to be there: it is the boundary between + // the two halves, and it carries the new-tab button. + if headings && block_count == projects_shown { + list = list.child(self.tabs_heading(projects_shown > 0 && !projects_folded, cx)); + } + if !any_rows && !query.is_empty() { list = list.child( div() @@ -983,12 +1156,8 @@ impl Tty7App { ) .child(div().flex_1().min_w(px(GRAB_HANDLE_W))) }) - .child( - div() - .occlude() - .flex_shrink_0() - .child(self.new_tab_button("sidebar-add", cx)), - ) + // No new-tab button here: it lives on the TABS heading, beside + // the half of the rail it adds to. .child( div().occlude().flex_shrink_0().child( crate::ui::tab_strip::chrome_tile( @@ -1227,6 +1396,129 @@ impl Tty7App { .then_some(info) } + /// Each project's id beside the name its header reads — an explicit name + /// when it has one, otherwise the folder title with the same collision + /// walk-up a repo group header does. + pub(crate) fn named_projects(&self) -> Vec<(ProjectId, String)> { + self.projects + .iter() + .map(|p| p.id) + .zip(crate::ui::projects::project_names(&self.projects)) + .collect() + } + + pub(crate) fn is_folded(&self, what: &SidebarFold) -> bool { + self.sidebar_folded.borrow().contains(what) + } + + pub(crate) fn toggle_fold(&self, what: SidebarFold) { + let mut folded = self.sidebar_folded.borrow_mut(); + if !folded.remove(&what) { + folded.insert(what); + } + } + + /// One of the rail's two top-level headings — the only thing that says + /// which half of the sidebar you are looking at: above the second one + /// everything was declared, below it everything was derived. + /// + /// Deliberately the quietest text in the rail, and the only text in it set + /// in capitals. That pair is what makes it read as chrome rather than as + /// one more block header: a heading that shared the block headers' size, + /// case and weight — which is what this was at first — left eight rows + /// that all looked alike and no way to tell what was inside what. + /// + /// The two halves are told apart by the space above the second heading + /// and nothing else. A hairline there was tried and looked like what it + /// was — a rule drawn edge to edge across a 200px rail, sitting right + /// under whatever the block above ended with. Every sidebar on this + /// platform separates its sections with whitespace and a quiet label; + /// none of them draws a line. + /// + /// `leading_gap` is the caller's answer to "is there anything up there to + /// be separated from". Whitespace between two headings with nothing + /// between them is not a separator, it is a hole. + fn rail_heading( + &self, + id: &'static str, + label: SharedString, + fold: SidebarFold, + leading_gap: bool, + add: A, + cx: &mut Context, + ) -> impl IntoElement + use { + let folded = self.is_folded(&fold); + h_flex() + .id(id) + .group(SharedString::from(id)) + .w_full() + .items_center() + .gap_1() + .pl_1() + .pr_1p5() + .pb_1() + .when(leading_gap, |h| h.mt_2()) + .pt_1p5() + .text_size(px(11.)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(cx.theme().muted_foreground) + .child(fold_chevron(folded, 11.)) + .child(div().flex_1().min_w_0().truncate().child(label)) + // Its own heading's hover, not the whole rail's: pointing anywhere + // in the sidebar used to fade both `+` in at once, which offered a + // button on a row the pointer was nowhere near. A control appears + // where the pointer is. + .child( + div() + .flex_shrink_0() + .opacity(0.) + .group_hover(SharedString::from(id), |s| s.opacity(1.)) + .child(add), + ) + .cursor_pointer() + .hover(|s| s.text_color(cx.theme().sidebar_foreground)) + .on_click(cx.listener(move |this, _, _window, cx| { + cx.stop_propagation(); + this.toggle_fold(fold.clone()); + cx.notify(); + })) + } + + fn projects_heading(&self, cx: &mut Context) -> impl IntoElement + use<> { + let add = crate::ui::tab_strip::hit_target( + Button::new("sidebar-project-add") + .icon(IconName::Plus) + .ghost() + .xsmall(), + ) + .tooltip(t(L10nKey::ProjectNewTooltip)) + .on_click(cx.listener(|this, _, window, cx| { + cx.stop_propagation(); + this.new_project(window, cx); + })); + self.rail_heading( + "sidebar-projects-heading", + SharedString::from(t(L10nKey::SidebarProjectsHeading).to_uppercase()), + SidebarFold::Projects, + // The first thing in the list; the gap above it is the list's own + // top padding. + false, + add, + cx, + ) + } + + fn tabs_heading(&self, leading_gap: bool, cx: &mut Context) -> impl IntoElement + use<> { + self.rail_heading( + "sidebar-tabs-heading", + SharedString::from(t(L10nKey::SidebarTabsHeading).to_uppercase()), + SidebarFold::Tabs, + leading_gap, + self.new_tab_heading_button("sidebar-add", cx), + cx, + ) + } + fn sidebar_group_keys(&self, cx: &gpui::App) -> Vec> { let grouping = cx.global::().sidebar_grouping; self.tabs @@ -1250,15 +1542,20 @@ impl Tty7App { .collect() } + /// The tabs the rail is showing, in the order it shows them — which is + /// what ⌘N counts and what a row's badge names. + /// + /// Rows inside a folded block are not on screen, so they are not in the + /// count: leaving them in would number the visible rows ⌘1, ⌘4, ⌘5, and + /// ⌘2 would open something nobody can see. fn visual_tab_order(&self, cx: &gpui::App) -> Vec { if cx.global::().tab_bar_position != crate::core::config::TabBarPosition::Left { return (0..self.tabs.len()).collect(); } let keys = self.sidebar_group_keys(cx); - sidebar_sections(&keys) - .into_iter() - .flat_map(|s| s.tabs) - .collect() + let members: Vec> = self.tabs.iter().map(|t| t.project.get()).collect(); + let sections = sidebar_sections(&keys, &members, &self.named_projects()); + on_screen(§ions, &self.sidebar_folded.borrow()) } pub(crate) fn activate_visual( @@ -1316,53 +1613,189 @@ fn resolved_group( }) } +/// What a block of rows in the sidebar is grouped by. +/// +/// Two of these are derived from where the tabs happen to be — a repo the +/// probe found, or nothing it could place — and one is declared. They share a +/// type because a row belongs to exactly one block whichever kind it is, and +/// everything that reads the sidebar's shape (⌘N order, in-block reordering, +/// which rows a drag may shuffle) wants that one answer. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) enum SectionKey { + /// A project someone declared, by its id. + Project(ProjectId), + /// A repo home the probe derived from a tab's cwd. + Repo(PathBuf), + /// The tabs the probe could not place. + Scratch, +} + +impl SectionKey { + fn repo(&self) -> Option<&PathBuf> { + match self { + SectionKey::Repo(root) => Some(root), + _ => None, + } + } + + fn project(&self) -> Option { + match self { + SectionKey::Project(id) => Some(*id), + _ => None, + } + } +} + +/// The tabs `sections` actually puts on screen, in row order, with every +/// folded block left out — the order ⌘N counts. +fn on_screen(sections: &[Section], folded: &std::collections::HashSet) -> Vec { + sections + .iter() + .filter(|s| { + let half = match s.key { + SectionKey::Project(_) => SidebarFold::Projects, + _ => SidebarFold::Tabs, + }; + !folded.contains(&half) && !folded.contains(&SidebarFold::Section(s.key.clone())) + }) + .flat_map(|s| s.tabs.clone()) + .collect() +} + +/// The chord number each tab wears, indexed the way `self.tabs` is, read off +/// the same [`on_screen`] order `activate_visual` walks — a badge taken from +/// anywhere else names a chord that opens a different tab. +/// +/// `None` for a tab that order left out, rather than a `0` that reads as ⌘1. +/// The rail can draw a row that order does not count: a live search deliberately +/// ignores a folded heading, so it shows rows the chord order has taken out. A +/// row with no number is the only honest thing to draw there — the alternative +/// is every one of those rows claiming ⌘1 while ⌘1 opens something else. +fn badge_positions(order: &[usize], tabs: usize) -> Vec> { + let mut pos = vec![None; tabs]; + for (n, &i) in order.iter().enumerate() { + if let Some(slot) = pos.get_mut(i) { + *slot = Some(n); + } + } + pos +} + +/// The twist that says whether a block is open, drawn where a disclosure +/// triangle goes — pointing down when what is under it is showing. +fn fold_chevron(folded: bool, size: f32) -> impl IntoElement { + Icon::new(if folded { + IconName::ChevronRight + } else { + IconName::ChevronDown + }) + .size(px(size)) + .flex_shrink_0() +} + +/// Something in the rail that folds shut, named by the header you click. +/// +/// Held in memory rather than in the config: re-opening a block is one click, +/// and the alternative is a map keyed by repo path — the orphaned-key problem +/// this whole feature was written to get away from. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) enum SidebarFold { + /// The PROJECTS heading, and with it every project under it. + Projects, + /// The TABS heading, and with it the whole derived grouping. + Tabs, + /// One block, header left showing. + Section(SectionKey), +} + #[derive(Debug, PartialEq)] struct Section { - key: Option, + key: SectionKey, name: Option, tabs: Vec, } -fn sidebar_sections(keys: &[Option]) -> Vec
{ +/// The sidebar's blocks, top to bottom: every declared project in the order +/// the workspace holds them, then the derived grouping over whatever tabs no +/// project has claimed. +/// +/// The second half is the whole of today's sidebar, unchanged — the only +/// difference is the tabs it is fed. `projects` carries each project's id +/// beside the name its header reads, resolved by the caller so this can be +/// tested without one. +fn sidebar_sections( + keys: &[Option], + members: &[Option], + projects: &[(ProjectId, String)], +) -> Vec
{ + let member_of = |i: usize| members.get(i).copied().flatten(); + let mut out: Vec
= projects + .iter() + .map(|(id, name)| Section { + key: SectionKey::Project(*id), + name: Some(name.clone()), + tabs: (0..keys.len()) + .filter(|&i| member_of(i) == Some(*id)) + .collect(), + }) + .collect(); + let loose: Vec = (0..keys.len()) + .filter(|&i| member_of(i).is_none()) + .collect(); + let mut group_order: Vec<&PathBuf> = Vec::new(); - for k in keys.iter().flatten() { + for k in loose.iter().filter_map(|&i| keys[i].as_ref()) { if !group_order.iter().any(|g| *g == k) { group_order.push(k); } } if group_order.is_empty() { - return vec![Section { - key: None, + if loose.is_empty() && !out.is_empty() { + return out; + } + out.push(Section { + key: SectionKey::Scratch, + // The only derived block there is, so it wears no header of its + // own: its rows sit directly under TABS, which is what a + // workspace with no repos has always looked like. A name would + // only be a second heading saying the same thing. name: None, - tabs: (0..keys.len()).collect(), - }]; + tabs: loose, + }); + return out; } let names = group_names(&group_order); - let mut sections: Vec
= group_order - .iter() - .zip(names) - .map(|(root, name)| Section { - key: Some((*root).clone()), + out.extend(group_order.iter().zip(names).map(|(root, name)| { + Section { + key: SectionKey::Repo((*root).clone()), name: Some(name), - tabs: (0..keys.len()) + tabs: loose + .iter() + .copied() .filter(|&i| keys[i].as_ref() == Some(*root)) .collect(), - }) + } + })); + let scratch: Vec = loose + .iter() + .copied() + .filter(|&i| keys[i].is_none()) .collect(); - let scratch: Vec = (0..keys.len()).filter(|&i| keys[i].is_none()).collect(); if !scratch.is_empty() { - sections.push(Section { - key: None, + out.push(Section { + key: SectionKey::Scratch, name: Some(t(L10nKey::SidebarScratchGroup).to_string()), tabs: scratch, }); } - sections + out } +/// The whole tab order with one row moved within its own block. Rows filtered +/// out by the search are not in `visible` and are left where they are. fn reordered_rows( - keys: &[Option], - group: &Option, + sections: &[Section], + key: &SectionKey, visible: &[usize], from: usize, to: usize, @@ -1371,46 +1804,44 @@ fn reordered_rows( if moved == anchor { return None; } - let mut members: Vec = (0..keys.len()).filter(|&i| keys[i] == *group).collect(); + let mut members = sections.iter().find(|s| s.key == *key)?.tabs.clone(); members.retain(|&i| i != moved); let at = members.iter().position(|&i| i == anchor)? + usize::from(to > from); members.insert(at, moved); - let mut out: Vec = Vec::with_capacity(keys.len()); - for g in sidebar_sections(keys).iter().map(|s| &s.key) { - if g == group { - out.extend_from_slice(&members); - } else { - out.extend((0..keys.len()).filter(|&i| keys[i] == *g)); - } - } - Some(out) + Some( + sections + .iter() + .flat_map(|s| { + if s.key == *key { + members.clone() + } else { + s.tabs.clone() + } + }) + .collect(), + ) } -fn regrouped_order(keys: &[Option], from: &Path, to: &Path) -> Option> { +/// The whole tab order with one block moved into another block's slot. +fn regrouped_order(sections: &[Section], from: &SectionKey, to: &SectionKey) -> Option> { if from == to { return None; } - let mut order: Vec<&PathBuf> = Vec::new(); - for k in keys.iter().flatten() { - if !order.iter().any(|g| *g == k) { - order.push(k); - } - } - let fi = order.iter().position(|g| g.as_path() == from)?; - let ti = order.iter().position(|g| g.as_path() == to)?; + let fi = sections.iter().position(|s| s.key == *from)?; + let ti = sections.iter().position(|s| s.key == *to)?; + let mut order: Vec = (0..sections.len()).collect(); let moved = order.remove(fi); order.insert(ti, moved); - - let mut out: Vec = Vec::with_capacity(keys.len()); - for g in &order { - out.extend((0..keys.len()).filter(|&i| keys[i].as_ref() == Some(*g))); - } - out.extend((0..keys.len()).filter(|&i| keys[i].is_none())); - Some(out) + Some( + order + .into_iter() + .flat_map(|i| sections[i].tabs.clone()) + .collect(), + ) } -fn group_names(roots: &[&PathBuf]) -> Vec { +pub(crate) fn group_names(roots: &[&PathBuf]) -> Vec { let comps: Vec> = roots .iter() .map(|r| { @@ -1527,6 +1958,20 @@ mod tests { } } + /// The two-argument shape every test below is written against: no + /// projects declared, so nothing is filed under one. + fn loose(n: usize) -> Vec> { + vec![None; n] + } + + fn sections_of(keys: &[Option]) -> Vec
{ + sidebar_sections(keys, &loose(keys.len()), &[]) + } + + fn flatten(sections: &[Section]) -> Vec { + sections.iter().flat_map(|s| s.tabs.clone()).collect() + } + #[test] fn sections_order_groups_by_first_appearance_scratch_last() { let keys = vec![ @@ -1535,26 +1980,139 @@ mod tests { Some(p("/w/alpha")), Some(p("/w/beta")), ]; - let sections = sidebar_sections(&keys); - let shape: Vec<(Option, Option, Vec)> = sections + let shape: Vec<(SectionKey, Option, Vec)> = sections_of(&keys) .into_iter() .map(|s| (s.key, s.name, s.tabs)) .collect(); assert_eq!( shape, vec![ - (Some(p("/w/beta")), Some("beta".into()), vec![0, 3]), - (Some(p("/w/alpha")), Some("alpha".into()), vec![2]), - (None, Some("Scratch".into()), vec![1]), + ( + SectionKey::Repo(p("/w/beta")), + Some("beta".into()), + vec![0, 3] + ), + ( + SectionKey::Repo(p("/w/alpha")), + Some("alpha".into()), + vec![2] + ), + (SectionKey::Scratch, Some("Scratch".into()), vec![1]), ] ); - let flat = sidebar_sections(&[None, None]); + let flat = sections_of(&[None, None]); assert_eq!(flat.len(), 1); - assert_eq!(flat[0].name, None); + assert_eq!( + flat[0].name, None, + "one block and nothing above it: no header" + ); assert_eq!(flat[0].tabs, vec![0, 1]); } + /// The whole point of the additive shape: the derived half is fed only the + /// tabs no project claimed, and is otherwise exactly what it was. + #[test] + fn a_project_takes_its_tabs_out_of_the_derived_grouping() { + let arb = ProjectId::new(); + let keys = vec![ + Some(p("/w/beta")), + Some(p("/w/alpha")), + Some(p("/w/beta")), + None, + ]; + // Tab 0 is filed under the project; the other tab in /w/beta is not. + let members = vec![Some(arb), None, None, None]; + let sections = sidebar_sections(&keys, &members, &[(arb, "套利研究".into())]); + let shape: Vec<(SectionKey, Option, Vec)> = sections + .into_iter() + .map(|s| (s.key, s.name, s.tabs)) + .collect(); + assert_eq!( + shape, + vec![ + (SectionKey::Project(arb), Some("套利研究".into()), vec![0]), + ( + SectionKey::Repo(p("/w/alpha")), + Some("alpha".into()), + vec![1] + ), + (SectionKey::Repo(p("/w/beta")), Some("beta".into()), vec![2]), + (SectionKey::Scratch, Some("Scratch".into()), vec![3]), + ], + "the repo group keeps the tab the project did not take, and \ + /w/alpha now leads because first appearance is read over the \ + tabs that are left" + ); + } + + /// A project stays when the last tab in it closes — that is the whole + /// difference between it and a group. + #[test] + fn a_project_with_no_tabs_is_still_a_section() { + let empty = ProjectId::new(); + let sections = sidebar_sections(&[None], &[None], &[(empty, "做市实验".into())]); + assert_eq!(sections[0].key, SectionKey::Project(empty)); + assert!(sections[0].tabs.is_empty()); + assert_eq!( + sections[1].name, None, + "the only derived block wears no header: its rows sit under TABS" + ); + } + + #[test] + fn every_tab_in_a_project_leaves_no_block_below() { + let only = ProjectId::new(); + let sections = sidebar_sections( + &[None, None], + &[Some(only), Some(only)], + &[(only, "p".into())], + ); + assert_eq!(sections.len(), 1); + assert_eq!(sections[0].tabs, vec![0, 1]); + } + + fn folds(of: &[SidebarFold]) -> std::collections::HashSet { + of.iter().cloned().collect() + } + + /// Folding is what is on screen, and ⌘N counts what is on screen. A row + /// inside a folded block is not a row, so it takes no number — otherwise + /// the visible rows would badge ⌘1, ⌘4, ⌘5 and ⌘2 would open something + /// nobody can see. + #[test] + fn a_folded_block_takes_its_rows_out_of_the_chord_order() { + let arb = ProjectId::new(); + let keys = vec![None, Some(p("/w/beta")), Some(p("/w/alpha")), None]; + let members = vec![Some(arb), None, None, None]; + let sections = sidebar_sections(&keys, &members, &[(arb, "arb".into())]); + assert_eq!(on_screen(§ions, &folds(&[])), vec![0, 1, 2, 3]); + assert_eq!( + on_screen( + §ions, + &folds(&[SidebarFold::Section(SectionKey::Repo(p("/w/beta")))]) + ), + vec![0, 2, 3] + ); + assert_eq!( + on_screen(§ions, &folds(&[SidebarFold::Projects])), + vec![1, 2, 3], + "folding a heading takes its whole half, headers and all" + ); + assert_eq!( + on_screen(§ions, &folds(&[SidebarFold::Tabs])), + vec![0], + "and the derived half is one heading, however many blocks it holds" + ); + assert!( + on_screen( + §ions, + &folds(&[SidebarFold::Projects, SidebarFold::Tabs]) + ) + .is_empty() + ); + } + /// The badge on a row and the tab ⌘N opens are two readings of one order, /// taken in two places. Grouping makes them diverge from `self.tabs` /// order — tab 3 sits in the second row here — so if they are ever read @@ -1568,20 +2126,15 @@ mod tests { Some(p("/w/beta")), ]; // What `visual_tab_order` returns for a left tab bar. - let order: Vec = sidebar_sections(&keys) - .into_iter() - .flat_map(|s| s.tabs) - .collect(); + let order = flatten(§ions_of(&keys)); assert_eq!(order, vec![0, 3, 2, 1]); - let mut badge_pos = vec![0usize; keys.len()]; - for (n, i) in order.iter().copied().enumerate() { - badge_pos[i] = n; - } + let badge_pos = badge_positions(&order, keys.len()); for (row, tab) in order.iter().copied().enumerate() { // ActivateTabN → activate_visual(N - 1) → order[N - 1]. - let chord = tab_badge_label(badge_pos[tab]); - let opens = order[badge_pos[tab]]; + let n = badge_pos[tab].expect("every tab in the order is badged"); + let chord = tab_badge_label(n); + let opens = order[n]; assert_eq!( opens, tab, "row {row} badges ⌘{chord}, which opens tab {opens}" @@ -1589,6 +2142,28 @@ mod tests { } } + /// A live search draws rows a folded heading has taken out of the chord + /// order — that is deliberate, the query is asking about tabs. So the two + /// disagree, and the badge has to say "no number" rather than fall back to + /// a `0` that reads as ⌘1 while ⌘1 opens something else entirely. + #[test] + fn a_row_the_chord_order_left_out_wears_no_badge() { + let arb = ProjectId::new(); + let keys = vec![None, Some(p("/w/beta")), None]; + let members = vec![Some(arb), None, Some(arb)]; + let sections = sidebar_sections(&keys, &members, &[(arb, "arb".into())]); + + let order = on_screen(§ions, &folds(&[SidebarFold::Projects])); + assert_eq!(order, vec![1], "the folded half is out of the order"); + + let badge_pos = badge_positions(&order, keys.len()); + assert_eq!(badge_pos, vec![None, Some(0), None]); + assert!( + badge_pos[0].is_none() && badge_pos[2].is_none(), + "a project row a search still draws must not claim ⌘1" + ); + } + #[test] fn reordered_rows_moves_within_the_group_only() { let keys = vec![ @@ -1597,24 +2172,38 @@ mod tests { Some(p("/w/alpha")), None, ]; - let alpha = Some(p("/w/alpha")); + let sections = sections_of(&keys); + let alpha = SectionKey::Repo(p("/w/alpha")); assert_eq!( - reordered_rows(&keys, &alpha, &[0, 2], 0, 1), + reordered_rows(§ions, &alpha, &[0, 2], 0, 1), Some(vec![2, 0, 1, 3]) ); assert_eq!( - reordered_rows(&keys, &alpha, &[0, 2], 1, 0), + reordered_rows(§ions, &alpha, &[0, 2], 1, 0), Some(vec![2, 0, 1, 3]) ); - assert_eq!(reordered_rows(&keys, &alpha, &[0, 2], 1, 1), None); + assert_eq!(reordered_rows(§ions, &alpha, &[0, 2], 1, 1), None); + } + + /// Rows inside a project reorder the same way rows inside a group do. + #[test] + fn reordered_rows_works_inside_a_project_too() { + let arb = ProjectId::new(); + let keys = vec![None, Some(p("/w/beta")), None]; + let members = vec![Some(arb), None, Some(arb)]; + let sections = sidebar_sections(&keys, &members, &[(arb, "arb".into())]); + assert_eq!( + reordered_rows(§ions, &SectionKey::Project(arb), &[0, 2], 0, 1), + Some(vec![2, 0, 1]) + ); } #[test] fn reordered_rows_leaves_filtered_out_rows_alone() { let keys = vec![Some(p("/w/a")), Some(p("/w/a")), Some(p("/w/a"))]; - let a = Some(p("/w/a")); + let sections = sections_of(&keys); assert_eq!( - reordered_rows(&keys, &a, &[0, 2], 0, 1), + reordered_rows(§ions, &SectionKey::Repo(p("/w/a")), &[0, 2], 0, 1), Some(vec![1, 2, 0]) ); } @@ -1628,12 +2217,14 @@ mod tests { Some(p("/w/alpha")), Some(p("/w/gamma")), ]; + let sections = sections_of(&keys); + let g = |path: &str| SectionKey::Repo(p(path)); assert_eq!( - regrouped_order(&keys, &p("/w/gamma"), &p("/w/alpha")), + regrouped_order(§ions, &g("/w/gamma"), &g("/w/alpha")), Some(vec![4, 0, 3, 2, 1]) ); assert_eq!( - regrouped_order(&keys, &p("/w/alpha"), &p("/w/gamma")), + regrouped_order(§ions, &g("/w/alpha"), &g("/w/gamma")), Some(vec![2, 4, 0, 3, 1]) ); } @@ -1641,9 +2232,20 @@ mod tests { #[test] fn regrouped_order_ignores_self_and_unknown_roots() { let keys = vec![Some(p("/w/alpha")), Some(p("/w/beta"))]; - assert_eq!(regrouped_order(&keys, &p("/w/alpha"), &p("/w/alpha")), None); - assert_eq!(regrouped_order(&keys, &p("/w/gone"), &p("/w/beta")), None); - assert_eq!(regrouped_order(&keys, &p("/w/alpha"), &p("/w/gone")), None); + let sections = sections_of(&keys); + let g = |path: &str| SectionKey::Repo(p(path)); + assert_eq!( + regrouped_order(§ions, &g("/w/alpha"), &g("/w/alpha")), + None + ); + assert_eq!( + regrouped_order(§ions, &g("/w/gone"), &g("/w/beta")), + None + ); + assert_eq!( + regrouped_order(§ions, &g("/w/alpha"), &g("/w/gone")), + None + ); } #[test] diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index eb4f5198..38ba86fc 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -846,6 +846,24 @@ pub(crate) fn select_workspace_action(index: usize) -> Option, + agent_here: bool, + agent_done: bool, + in_repo: bool, + agent_session: Option<( + gpui::Entity, + crate::ui::app::TabAgentSession, + )>, + projects: Vec<(tty7_core::core::machine::ProjectId, String)>, + filed_under: Option, + has_folder: bool, +} + impl Tty7App { pub(crate) const AVATAR_PX: f32 = 20.0; @@ -1313,6 +1331,31 @@ impl Tty7App { }) } + /// The new-tab button as the rail's TABS heading wears it: the same + /// dropdown, sized to sit in an 11px header rather than in the title bar. + pub(crate) fn new_tab_heading_button( + &self, + id: &'static str, + cx: &Context, + ) -> impl IntoElement + use<> { + let app = cx.entity().downgrade(); + hit_target( + Button::new(id) + .icon(Icon::new(IconName::Plus)) + .ghost() + .xsmall(), + ) + .tooltip(chord_hint(t(L10nKey::AppMenuNewTab), "NewTab", cx)) + .dropdown_menu(move |menu, window, cx| { + let Some(this) = app.upgrade() else { + return menu; + }; + this.read(cx) + .new_tab_menu_rows(app.clone(), cx) + .build(menu, window) + }) + } + /// What the menu offers, read off the app as the menu opens — the builder /// runs on the popup's own entity, so the rows carry a weak handle back. fn new_tab_menu_rows(&self, app: gpui::WeakEntity, cx: &App) -> NewTabMenu { @@ -1329,20 +1372,98 @@ impl Tty7App { } } + /// What a project header offers. Everything here is a deliberate act on + /// the project itself — nothing about it is ever inferred, which is the + /// whole difference between this block of rows and the derived ones below + /// it in the rail. + pub(crate) fn project_context_menu( + menu: PopupMenu, + project: tty7_core::core::machine::ProjectId, + app: &gpui::WeakEntity, + _window: &mut Window, + cx: &mut Context, + ) -> PopupMenu { + let Some(entity) = app.upgrade() else { + return menu; + }; + // The folder panel browses the machine this window runs on, so a + // remote workspace is offered everything here except that. + let local = !entity.read(cx).workspace_is_remote(cx); + menu.min_w(px(200.)) + .item(PopupMenuItem::new(t(L10nKey::ProjectNewTab)).on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| this.new_tab_in_project(project, window, cx)); + } + })) + .separator() + .item(PopupMenuItem::new(t(L10nKey::ProjectRename)).on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| { + this.start_project_rename(project, window, cx) + }); + } + })) + .item( + PopupMenuItem::new(t(L10nKey::ProjectSetFolder)) + .disabled(!local) + .on_click({ + let app = app.clone(); + move |_, _window, cx| { + let _ = app.update(cx, |this, cx| this.pick_project_root(project, cx)); + } + }), + ) + .separator() + .item(PopupMenuItem::new(t(L10nKey::ProjectDelete)).on_click({ + let app = app.clone(); + move |_, _window, cx| { + let _ = app.update(cx, |this, cx| this.delete_project(project, cx)); + } + })) + } + pub(crate) fn tab_context_menu( menu: PopupMenu, index: usize, below_wording: bool, app: &gpui::WeakEntity, - window: &Window, - cx: &App, + window: &mut Window, + cx: &mut Context, ) -> PopupMenu { let Some(entity) = app.upgrade() else { return menu; }; - let this = entity.read(cx); - let tab_count = this.tabs.len(); - let cwd = this.tab_cwd_text(index, window, cx); + // Everything the rows are built from is read out of the app up front + // and the borrow released: a submenu is built through `cx` itself, and + // holding a read of the app across that call is a borrow conflict. + let Facts { + tab_count, + cwd, + agent_here, + agent_done, + in_repo, + agent_session, + projects, + filed_under, + has_folder, + } = { + let this = entity.read(cx); + let tab = this.tabs.get(index); + Facts { + tab_count: this.tabs.len(), + cwd: this.tab_cwd_text(index, window, cx), + agent_here: tab.is_some_and(|t| t.agent(cx).is_some()), + agent_done: tab.and_then(|t| t.agent_status(cx)) + == Some(crate::core::cli_agent::AgentStatus::Done), + in_repo: this.tab_is_in_repo(index, window, cx), + agent_session: this.tab_agent_session(index, window, cx), + projects: this.named_projects(), + filed_under: tab.and_then(|t| t.project.get()), + has_folder: this.tab_project_root(index, window, cx).is_some(), + } + }; let has_cwd = cwd.is_some(); let mut menu = menu.min_w(px(200.)); @@ -1363,14 +1484,55 @@ impl Tty7App { }), ); - let tab = this.tabs.get(index); - if tab.is_some_and(|t| t.agent(cx).is_some()) { - let done = tab.and_then(|t| t.agent_status(cx)) - == Some(crate::core::cli_agent::AgentStatus::Done); + // Where this tab is filed. Only a user action ever writes it, so + // these are the only way in and out of a project. + if !projects.is_empty() { + let app = app.clone(); + menu = menu.submenu( + t(L10nKey::TabProjectAddTo), + window, + cx, + move |mut sub, _w, _cx| { + for (id, name) in &projects { + let (id, app) = (*id, app.clone()); + sub = sub.item( + PopupMenuItem::new(SharedString::from(name.clone())) + .disabled(filed_under == Some(id)) + .on_click(move |_, _window, cx| { + let _ = app.update(cx, |this, cx| { + this.set_tab_project(index, Some(id), cx) + }); + }), + ); + } + sub + }, + ); + } + if filed_under.is_some() { + menu = menu.item(PopupMenuItem::new(t(L10nKey::TabProjectRemove)).on_click({ + let app = app.clone(); + move |_, _window, cx| { + let _ = app.update(cx, |this, cx| this.set_tab_project(index, None, cx)); + } + })); + } + menu = menu.item( + PopupMenuItem::new(t(L10nKey::TabProjectFromFolder)) + .disabled(!has_folder) + .on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| this.project_from_tab(index, window, cx)); + } + }), + ); + + if agent_here { menu = menu.item( PopupMenuItem::new(t(L10nKey::TabContextMarkUnread)) .action(Box::new(MarkTabUnread)) - .disabled(!done) + .disabled(!agent_done) .on_click({ let app = app.clone(); move |_, _window, cx| { @@ -1380,7 +1542,6 @@ impl Tty7App { ); } - let in_repo = this.tab_is_in_repo(index, window, cx); if in_repo { menu = menu.separator().item( PopupMenuItem::new(t(L10nKey::AppMenuNewWorktreeTab)) @@ -1395,7 +1556,6 @@ impl Tty7App { ); } - let agent_session = this.tab_agent_session(index, window, cx); if let Some((source, session)) = &agent_session && let Some(label) = session.fork_label { diff --git a/src/ui/tree_sync.rs b/src/ui/tree_sync.rs index 5ea14edc..0d2f10b7 100644 --- a/src/ui/tree_sync.rs +++ b/src/ui/tree_sync.rs @@ -5,8 +5,8 @@ use std::sync::Arc; use gpui::{App, Global}; use gpui_component::WindowExt as _; use tty7_core::core::machine::{ - AgentFacts, Axis as TreeAxis, LayoutDelta, Machine, PaneNode, PaneRecord, PaneSeed, Side, - Tab as TreeTab, TabId, Workspace, + AgentFacts, Axis as TreeAxis, LayoutDelta, Machine, PaneNode, PaneRecord, PaneSeed, Project, + ProjectId, Side, Tab as TreeTab, TabId, Workspace, }; use tty7_core::daemon::control::{ControlClient, ControlRequest, ReplyOk}; use tty7_core::host::HostId; @@ -63,6 +63,7 @@ pub(crate) struct DesiredTab { pub id: TabId, pub name: Option, pub group: Option, + pub project: Option, pub root: DesiredNode, } @@ -137,6 +138,7 @@ pub(crate) fn desired_tabs( .borrow() .as_ref() .map(|p| p.to_string_lossy().into_owned()), + project: tab.project.get(), root, }); } @@ -258,6 +260,7 @@ fn seeded_records(desired: &[DesiredTab], live: impl Fn(u64) -> bool) -> Vec, pub active: Option, + pub projects: Vec, } #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -270,12 +273,17 @@ pub(crate) fn diff( workspace: WorkspaceId, mirror: &mut WsMirror, desired: &[DesiredTab], + desired_projects: &[Project], desired_active: Option, scope: SyncScope, held: &[TabId], ) -> Vec { let mut ops = Vec::new(); + // Projects first, in both directions: a tab may only name one that + // already exists, and one may only be deleted once no tab names it. + reconcile_projects(workspace, mirror, desired_projects, &mut ops); + if scope == SyncScope::Full { migrate_panes(workspace, mirror, desired, &mut ops); let mut index = 0; @@ -315,6 +323,11 @@ pub(crate) fn diff( create_tab(workspace, mirror, at, want, &mut ops); } + if scope == SyncScope::Full { + retire_projects(workspace, mirror, desired_projects, &mut ops); + reorder_projects(workspace, mirror, desired_projects, &mut ops); + } + if scope == SyncScope::Additive || !held.is_empty() { return ops; } @@ -350,6 +363,111 @@ pub(crate) fn diff( ops } +/// Brings the machine's project list to the one the window is showing. +/// +/// Creates and edits run before the tab pass so a tab can be filed straight +/// away; deletions run after it, in `retire_projects`, because a project the +/// machine still has members for cannot go until they have been let out. +fn reconcile_projects( + workspace: WorkspaceId, + mirror: &mut WsMirror, + desired: &[Project], + ops: &mut Vec, +) { + for (index, want) in desired.iter().enumerate() { + match mirror.projects.iter().position(|p| p.id == want.id) { + Some(at) => { + if mirror.projects[at].name != want.name { + mirror.projects[at].name = want.name.clone(); + ops.push(ControlRequest::ProjectRename { + workspace, + project: want.id, + name: want.name.clone(), + }); + } + if mirror.projects[at].root != want.root { + mirror.projects[at].root = want.root.clone(); + ops.push(ControlRequest::ProjectSetRoot { + workspace, + project: want.id, + root: want.root.clone(), + }); + } + } + None => { + let at = index.min(mirror.projects.len()); + mirror.projects.insert(at, want.clone()); + ops.push(ControlRequest::ProjectCreate { + workspace, + at: Some(at as u64), + project: want.clone(), + }); + } + } + } +} + +/// Puts the machine's projects in the order the window shows them. +/// +/// Runs after `retire_projects`, not beside the creates above: `to` is an index +/// into the machine's whole list, so a project that is about to be deleted but +/// is still in it would push every index along and spell a move for a project +/// that is already where it belongs. Ordering answers to nothing else — no tab +/// references a position — so it is free to go last, once the list holds +/// exactly what it should. +fn reorder_projects( + workspace: WorkspaceId, + mirror: &mut WsMirror, + desired: &[Project], + ops: &mut Vec, +) { + for (index, want) in desired.iter().enumerate() { + let at = mirror + .projects + .iter() + .position(|p| p.id == want.id) + .expect("every desired project exists after the passes above"); + if at != index { + let moved = mirror.projects.remove(at); + mirror.projects.insert(index, moved); + ops.push(ControlRequest::ProjectMove { + workspace, + project: want.id, + to: index as u64, + }); + } + } +} + +/// Deletes the projects the window no longer shows. Runs after the tab pass: +/// every tab that named one has been let out of it by then, so the machine +/// never has to guess what a deletion does to its members. +fn retire_projects( + workspace: WorkspaceId, + mirror: &mut WsMirror, + desired: &[Project], + ops: &mut Vec, +) { + let mut index = 0; + while index < mirror.projects.len() { + let id = mirror.projects[index].id; + if desired.iter().any(|p| p.id == id) { + index += 1; + continue; + } + mirror.projects.remove(index); + for tab in &mut mirror.tabs { + if tab.project == Some(id) { + tab.project = None; + } + } + ops.push(ControlRequest::ProjectDelete { + workspace, + project: id, + }); + } +} + /// Carries panes across to the tab that now wants them, before anything else /// gets a chance to read their old tab as one to close. /// @@ -507,12 +625,20 @@ fn create_tab( group: want.group.clone(), }); } + if want.project.is_some() { + ops.push(ControlRequest::TabSetProject { + workspace, + tab: want.id, + project: want.project, + }); + } mirror.tabs.insert( index.min(mirror.tabs.len()), TreeTab { id: want.id, name: want.name.clone(), sidebar_group: want.group.clone(), + project: want.project, root, }, ); @@ -568,6 +694,14 @@ fn reconcile_tab( group: want.group.clone(), }); } + if tab.project != want.project { + tab.project = want.project; + ops.push(ControlRequest::TabSetProject { + workspace, + tab: want.id, + project: want.project, + }); + } } let desired_root = want.root.to_pane_node(); @@ -1000,14 +1134,26 @@ pub(crate) fn sync_window(app: &Tty7App, cx: &mut App) { .not_rebuilt .retain(|id| mirror.tabs.iter().any(|t| t.id == *id)); held.extend(state.not_rebuilt.iter().copied()); - let ops = diff(machine_ws, mirror, &desired, desired_active, scope, &held); + let ops = diff( + machine_ws, + mirror, + &desired, + &app.projects, + desired_active, + scope, + &held, + ); if !ops.is_empty() { let (tabs, active) = (mirror.tabs.clone(), mirror.active); + let projects = mirror.projects.clone(); state.queue.extend(ops); let host = WorkspaceStore::host_of(cx, client_ws); crate::ui::machine_mirror::MachineMirrors::note_synced_workspace( cx, host, machine_ws, tabs, active, ); + crate::ui::machine_mirror::MachineMirrors::note_synced_projects( + cx, host, machine_ws, projects, + ); let open: Vec = app .tabs .iter() @@ -1382,6 +1528,7 @@ fn primed(ws: Workspace) -> (WsMirror, Option) { WsMirror { tabs: ws.tabs, active: ws.active_tab, + projects: ws.projects, }, ws.name, ) @@ -1407,7 +1554,12 @@ fn finish_prime( // The machine answered, which is the only thing the retry was // waiting to find out, so the next failure starts its backoff over. state.rehydrate_attempts = 0; - let landed = (mirror.tabs.clone(), mirror.active, name); + let landed = ( + mirror.tabs.clone(), + mirror.active, + name, + mirror.projects.clone(), + ); state.sync = SyncPhase::Primed(mirror); landed } @@ -1429,14 +1581,27 @@ fn finish_prime( // name — it is left out of the deltas its own create raises (#604). let name = settle_chosen_name(cx, client_ws, landed.2); crate::ui::machine_mirror::MachineMirrors::note_workspace_name(cx, host, machine_ws, name); + let app = cx + .has_global::() + .then(|| crate::ui::windows::WindowRegistry::app_for(cx, client_ws)) + .flatten() + .and_then(|app| app.upgrade()); + let Some(app) = app else { + return; + }; + // Before anything is pushed, and whether or not there was an edit waiting. + // A prime keeps the window's own layout, so the diff that follows speaks + // for its projects too — and a window that never learned the machine's + // would read a workspace holding a project with no tabs in it as a project + // the user had deleted, and delete it. + app.update(cx, |app, cx| { + if adopt_projects(app, &landed.3) { + cx.notify(); + } + }); if !was_dirty { return; } - let Some(app) = - crate::ui::windows::WindowRegistry::app_for(cx, client_ws).and_then(|app| app.upgrade()) - else { - return; - }; app.update(cx, |app, cx| sync_window(app, cx)); } @@ -1518,6 +1683,7 @@ pub(crate) fn session_from_tree( name: tab.name.clone(), tree_id: Some(tab.id), sidebar_group: tab.sidebar_group.clone().map(std::path::PathBuf::from), + project: tab.project, pane: session_pane_from_node(&tab.root, panes), }) .collect(); @@ -1525,7 +1691,11 @@ pub(crate) fn session_from_tree( .active_tab .and_then(|id| ws.tabs.iter().position(|t| t.id == id)) .unwrap_or(0); - Session { active, tabs } + Session { + active, + tabs, + projects: ws.projects.clone(), + } } fn session_pane_from_node(node: &PaneNode, panes: &[PaneRecord]) -> SessionPane { @@ -1978,6 +2148,7 @@ fn layout_of( let mirror = WsMirror { tabs: ws.tabs.clone(), active: ws.active_tab, + projects: ws.projects.clone(), }; let session = session_from_tree(ws, &machine.panes); Ok((machine, mirror, session)) @@ -2044,6 +2215,7 @@ fn settle_hydration( let name = settle_chosen_name(cx, client_ws, answered); crate::ui::machine_mirror::MachineMirrors::note_workspace_name(cx, host, machine_ws, name); let machine_was_empty = mirror.tabs.is_empty(); + let pulled_projects = mirror.projects.clone(); let was_dirty = { let Some(state) = cx.default_global::().windows.get_mut(&client_ws) else { return false; @@ -2063,6 +2235,16 @@ fn settle_hydration( else { return false; }; + // The two branches below leave the window's layout alone, so its projects + // are left alone too — with whatever the tree had folded in, because a + // project with no tabs in it is a project the tab list cannot speak for. + // The rebuild further down needs none of this: it installs the tree's + // projects wholesale along with the tree's tabs. + app.update(cx, |app, cx| { + if adopt_projects(app, &pulled_projects) { + cx.notify(); + } + }); if adopt == Adopt::IfEmpty && !app.read(cx).tabs.is_empty() { // A full window over an empty tree has to write itself back, whether // or not an edit was waiting: the machine is missing tabs this window @@ -2115,6 +2297,29 @@ fn settle_hydration( true } +/// Folds the machine's projects into the window's, keyed by id and led by the +/// machine's order. +/// +/// A union rather than a replacement because this runs where the window keeps +/// its own layout: a project it declared while the machine was unreachable is +/// still the user's, and the next `sync_window` is what puts it there. +/// Answers whether the window's list actually changed, so the caller can skip +/// a repaint on the common case where the pull said nothing new. +#[must_use] +fn adopt_projects(app: &mut Tty7App, pulled: &[Project]) -> bool { + let mut merged = pulled.to_vec(); + for own in &app.projects { + if !merged.iter().any(|p| p.id == own.id) { + merged.push(own.clone()); + } + } + if merged == app.projects { + return false; + } + app.projects = merged; + true +} + /// What a rebuild leaves the window entitled to say, from how many tabs the /// tree asked it to put up (`wanted`), which ids those were (`wanted_ids`), /// and the tabs it is showing now (`showing`). @@ -2330,6 +2535,53 @@ fn apply_to_mirror(mirror: &mut WsMirror, delta: &LayoutDelta) -> bool { t.sidebar_group = group.clone(); true } + LayoutDelta::TabProjectChanged { tab, project } => { + let Some(t) = mirror.tabs.iter_mut().find(|t| t.id == *tab) else { + return false; + }; + t.project = *project; + true + } + LayoutDelta::ProjectCreated { at, project } => { + mirror.projects.retain(|p| p.id != project.id); + let at = (*at).min(mirror.projects.len()); + mirror.projects.insert(at, project.clone()); + true + } + LayoutDelta::ProjectRenamed { project, name } => { + let Some(p) = mirror.projects.iter_mut().find(|p| p.id == *project) else { + return false; + }; + p.name = name.clone(); + true + } + LayoutDelta::ProjectRerooted { project, root } => { + let Some(p) = mirror.projects.iter_mut().find(|p| p.id == *project) else { + return false; + }; + p.root = root.clone(); + true + } + LayoutDelta::ProjectMoved { project, to } => { + let Some(from) = mirror.projects.iter().position(|p| p.id == *project) else { + return false; + }; + let moved = mirror.projects.remove(from); + mirror + .projects + .insert((*to).min(mirror.projects.len()), moved); + true + } + LayoutDelta::ProjectDeleted { project } => { + let before = mirror.projects.len(); + mirror.projects.retain(|p| p.id != *project); + for tab in &mut mirror.tabs { + if tab.project == Some(*project) { + tab.project = None; + } + } + mirror.projects.len() != before + } LayoutDelta::TabMoved { tab, to } => { let Some(from) = mirror.tabs.iter().position(|t| t.id == *tab) else { return false; @@ -2480,6 +2732,46 @@ impl Tty7App { } true } + LayoutDelta::TabProjectChanged { tab, project } => { + if let Some(index) = index_of(&self.tabs, *tab) { + self.tabs[index].project.set(*project); + } + true + } + LayoutDelta::ProjectCreated { at, project } => { + self.projects.retain(|p| p.id != project.id); + let at = (*at).min(self.projects.len()); + self.projects.insert(at, project.clone()); + true + } + LayoutDelta::ProjectRenamed { project, name } => { + if let Some(p) = self.projects.iter_mut().find(|p| p.id == *project) { + p.name = name.clone(); + } + true + } + LayoutDelta::ProjectRerooted { project, root } => { + if let Some(p) = self.projects.iter_mut().find(|p| p.id == *project) { + p.root = root.clone(); + } + true + } + LayoutDelta::ProjectMoved { project, to } => { + if let Some(from) = self.projects.iter().position(|p| p.id == *project) { + let moved = self.projects.remove(from); + self.projects.insert((*to).min(self.projects.len()), moved); + } + true + } + LayoutDelta::ProjectDeleted { project } => { + self.projects.retain(|p| p.id != *project); + for tab in &self.tabs { + if tab.project.get() == Some(*project) { + tab.project.set(None); + } + } + true + } LayoutDelta::TabMoved { tab, to } => { if let Some(from) = index_of(&self.tabs, *tab) { let active_id = self.tabs.get(self.active).map(|t| t.tree_id.get()); @@ -2580,6 +2872,7 @@ impl Tty7App { gui.pane = pane; gui.name = tab.name.clone(); *gui.sidebar_group.borrow_mut() = tab.sidebar_group.clone().map(std::path::PathBuf::from); + gui.project.set(tab.project); self.maximized = None; true } @@ -3081,12 +3374,18 @@ mod tests { dirty: false, priming: false, }; - let primed_with = - |tabs: Vec| SyncPhase::Primed(WsMirror { tabs, active: None }); + let primed_with = |tabs: Vec| { + SyncPhase::Primed(WsMirror { + tabs, + active: None, + projects: Vec::new(), + }) + }; let a_tab = || TreeTab { id: TabId::new(), name: None, sidebar_group: None, + project: None, root: PaneNode::Leaf { pane: 1 }, }; @@ -3156,6 +3455,7 @@ mod tests { // already drained the way `save_session` drains it on the way // out of a window that has no tabs left. state.sync = SyncPhase::Primed(WsMirror { + projects: Vec::new(), tabs: vec![], active: None, }); @@ -3585,6 +3885,7 @@ mod tests { .unwrap(); state.rehydrate = None; state.sync = SyncPhase::Primed(WsMirror { + projects: Vec::new(), tabs: vec![TreeTab::leaf(1), TreeTab::leaf(2)], active: None, }); @@ -3712,17 +4013,20 @@ mod tests { .entry(ws) .or_default(); state.sync = SyncPhase::Primed(WsMirror { + projects: Vec::new(), tabs: vec![ TreeTab { id: put_up, name: None, sidebar_group: None, + project: None, root: PaneNode::Leaf { pane: 1 }, }, TreeTab { id: failed, name: None, sidebar_group: None, + project: None, root: PaneNode::Leaf { pane: 2 }, }, ], @@ -3803,17 +4107,20 @@ mod tests { .entry(there_id) .or_default(); state.sync = SyncPhase::Primed(WsMirror { + projects: Vec::new(), tabs: vec![ TreeTab { id: theirs.0, name: None, sidebar_group: None, + project: None, root: PaneNode::Leaf { pane: 11 }, }, TreeTab { id: theirs.1, name: None, sidebar_group: None, + project: None, root: PaneNode::Leaf { pane: 12 }, }, ], @@ -3887,6 +4194,7 @@ mod tests { state.epoch }; let advanced = WsMirror { + projects: Vec::new(), tabs: vec![TreeTab::leaf(7)], active: None, }; @@ -3943,6 +4251,7 @@ mod tests { id, name: None, group: None, + project: None, root, } } @@ -3977,6 +4286,198 @@ mod tests { } } + /// A project has to exist on the machine before a tab may name it, and it + /// may only be deleted once no tab does — so the two edits bracket the tab + /// pass rather than sitting beside it. + #[test] + fn a_project_is_created_before_its_tabs_and_deleted_after_them() { + let ws = WorkspaceId::new(); + let id = TabId::new(); + let project = Project::at("/repo/tty7"); + let mut mirror = WsMirror::default(); + let mut want = tab(id, leaf(7)); + want.project = Some(project.id); + + let ops = diff( + ws, + &mut mirror, + &[want.clone()], + std::slice::from_ref(&project), + Some(id), + SyncScope::Full, + &[], + ); + assert_eq!( + ops, + vec![ + ControlRequest::ProjectCreate { + workspace: ws, + at: Some(0), + project: project.clone(), + }, + ControlRequest::TabCreate { + workspace: ws, + at: Some(0), + pane: seed(7), + tab: Some(id), + }, + ControlRequest::TabSetProject { + workspace: ws, + tab: id, + project: Some(project.id), + }, + ] + ); + assert_eq!(mirror.projects, vec![project.clone()]); + assert_eq!(mirror.tabs[0].project, Some(project.id)); + + // The project is deleted while its tab stays open: the tab leaves it + // first, and only then does the project go. + let loose = tab(id, leaf(7)); + let ops = diff( + ws, + &mut mirror, + &[loose], + &[], + Some(id), + SyncScope::Full, + &[], + ); + assert_eq!( + ops, + vec![ + ControlRequest::TabSetProject { + workspace: ws, + tab: id, + project: None, + }, + ControlRequest::ProjectDelete { + workspace: ws, + project: project.id, + }, + ] + ); + assert!(mirror.projects.is_empty()); + assert_eq!(mirror.tabs.len(), 1, "the tab is not closed with it"); + } + + /// An additive sync speaks only for the tabs it is showing, so it never + /// deletes a project it happens not to know about. + #[test] + fn an_additive_sync_adds_projects_but_removes_none() { + let ws = WorkspaceId::new(); + let known = Project::at("/w/known"); + let mut mirror = WsMirror { + projects: vec![known.clone()], + ..WsMirror::default() + }; + let ops = diff(ws, &mut mirror, &[], &[], None, SyncScope::Additive, &[]); + assert!(ops.is_empty()); + assert_eq!(mirror.projects, vec![known]); + } + + /// Ordering runs after the deletions, so a project on its way out does not + /// push the survivors along and spell a move for one already in place. + #[test] + fn deleting_a_project_above_another_does_not_move_the_one_below() { + let ws = WorkspaceId::new(); + let doomed = Project::at("/w/doomed"); + let keeper = Project::at("/w/keeper"); + let mut mirror = WsMirror { + projects: vec![doomed.clone(), keeper.clone()], + ..WsMirror::default() + }; + + let ops = diff( + ws, + &mut mirror, + &[], + std::slice::from_ref(&keeper), + None, + SyncScope::Full, + &[], + ); + assert_eq!( + ops, + vec![ControlRequest::ProjectDelete { + workspace: ws, + project: doomed.id, + }], + "the delete is the whole of it — no ProjectMove for the survivor" + ); + assert_eq!(mirror.projects, vec![keeper]); + } + + /// Reordering on its own is still an edit, not a rebuild. + #[test] + fn reordering_projects_is_one_move_each() { + let ws = WorkspaceId::new(); + let (a, b) = (Project::at("/w/a"), Project::at("/w/b")); + let mut mirror = WsMirror { + projects: vec![a.clone(), b.clone()], + ..WsMirror::default() + }; + + let ops = diff( + ws, + &mut mirror, + &[], + &[b.clone(), a.clone()], + None, + SyncScope::Full, + &[], + ); + assert_eq!( + ops, + vec![ControlRequest::ProjectMove { + workspace: ws, + project: b.id, + to: 0, + }] + ); + assert_eq!(mirror.projects, vec![b, a]); + } + + #[test] + fn renaming_and_moving_a_project_are_edits_not_a_rebuild() { + let ws = WorkspaceId::new(); + let project = Project::at("/repo/tty7"); + let mut mirror = WsMirror { + projects: vec![project.clone()], + ..WsMirror::default() + }; + let renamed = Project { + name: Some("套利研究".into()), + root: "/repo/026/tty7".into(), + ..project.clone() + }; + let ops = diff( + ws, + &mut mirror, + &[], + std::slice::from_ref(&renamed), + None, + SyncScope::Full, + &[], + ); + assert_eq!( + ops, + vec![ + ControlRequest::ProjectRename { + workspace: ws, + project: project.id, + name: Some("套利研究".into()), + }, + ControlRequest::ProjectSetRoot { + workspace: ws, + project: project.id, + root: "/repo/026/tty7".into(), + }, + ] + ); + assert_eq!(mirror.projects, vec![renamed]); + } + #[test] fn opening_the_first_tab_emits_a_create_carrying_the_client_identity() { let ws = WorkspaceId::new(); @@ -3984,7 +4485,15 @@ mod tests { let mut mirror = WsMirror::default(); let desired = vec![tab(id, leaf(7))]; - let ops = diff(ws, &mut mirror, &desired, Some(id), SyncScope::Full, &[]); + let ops = diff( + ws, + &mut mirror, + &desired, + &[], + Some(id), + SyncScope::Full, + &[], + ); assert_eq!( ops, vec![ControlRequest::TabCreate { @@ -4005,10 +4514,10 @@ mod tests { let id = TabId::new(); let mut mirror = WsMirror::default(); let one = vec![tab(id, leaf(1))]; - diff(ws, &mut mirror, &one, Some(id), SyncScope::Full, &[]); + diff(ws, &mut mirror, &one, &[], Some(id), SyncScope::Full, &[]); let two = vec![tab(id, split(TreeAxis::Vertical, 0.5, leaf(1), leaf(2)))]; - let ops = diff(ws, &mut mirror, &two, Some(id), SyncScope::Full, &[]); + let ops = diff(ws, &mut mirror, &two, &[], Some(id), SyncScope::Full, &[]); assert_eq!( ops, vec![ControlRequest::PaneSplit { @@ -4032,13 +4541,14 @@ mod tests { ws, &mut mirror, &[tab(id, leaf(1))], + &[], Some(id), SyncScope::Full, &[], ); let want = vec![tab(id, split(TreeAxis::Horizontal, 0.4, leaf(2), leaf(1)))]; - let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]); + let ops = diff(ws, &mut mirror, &want, &[], Some(id), SyncScope::Full, &[]); assert_eq!( ops, vec![ControlRequest::PaneSplit { @@ -4062,13 +4572,14 @@ mod tests { ws, &mut mirror, &[tab(id, split(TreeAxis::Vertical, 0.5, leaf(1), leaf(2)))], + &[], Some(id), SyncScope::Full, &[], ); let want = vec![tab(id, leaf(1))]; - let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]); + let ops = diff(ws, &mut mirror, &want, &[], Some(id), SyncScope::Full, &[]); assert_eq!( ops, vec![ControlRequest::PaneClose { @@ -4092,14 +4603,22 @@ mod tests { id, grid(split(TreeAxis::Horizontal, 0.5, leaf(1), leaf(2)), leaf(3)), )]; - diff(ws, &mut mirror, &before, Some(id), SyncScope::Full, &[]); + diff( + ws, + &mut mirror, + &before, + &[], + Some(id), + SyncScope::Full, + &[], + ); // 1 dropped below 3, which leaves 2 holding the top row alone. let after = vec![tab( id, grid(leaf(2), split(TreeAxis::Vertical, 0.5, leaf(3), leaf(1))), )]; - let ops = diff(ws, &mut mirror, &after, Some(id), SyncScope::Full, &[]); + let ops = diff(ws, &mut mirror, &after, &[], Some(id), SyncScope::Full, &[]); assert_eq!( ops, vec![ControlRequest::PaneMove { @@ -4123,7 +4642,15 @@ mod tests { tab(host, leaf(1)), tab(guest, split(TreeAxis::Horizontal, 0.5, leaf(2), leaf(3))), ]; - diff(ws, &mut mirror, &before, Some(host), SyncScope::Full, &[]); + diff( + ws, + &mut mirror, + &before, + &[], + Some(host), + SyncScope::Full, + &[], + ); // The guest tab dropped on the right of pane 1, arriving as the column // of two it already was. @@ -4136,7 +4663,15 @@ mod tests { split(TreeAxis::Vertical, 0.5, leaf(2), leaf(3)), ), )]; - let ops = diff(ws, &mut mirror, &after, Some(host), SyncScope::Full, &[]); + let ops = diff( + ws, + &mut mirror, + &after, + &[], + Some(host), + SyncScope::Full, + &[], + ); assert_eq!( ops, vec![ @@ -4171,7 +4706,15 @@ mod tests { tab(host, split(TreeAxis::Horizontal, 0.5, leaf(1), leaf(2))), tab(guest, leaf(3)), ]; - diff(ws, &mut mirror, &before, Some(host), SyncScope::Full, &[]); + diff( + ws, + &mut mirror, + &before, + &[], + Some(host), + SyncScope::Full, + &[], + ); // Dropped against the host's outer edge, so the newcomer sits above the // whole two-pane layout rather than beside one of its panes. No single @@ -4187,7 +4730,15 @@ mod tests { split(TreeAxis::Horizontal, 0.5, leaf(1), leaf(2)), ), )]; - diff(ws, &mut mirror, &after, Some(host), SyncScope::Full, &[]); + diff( + ws, + &mut mirror, + &after, + &[], + Some(host), + SyncScope::Full, + &[], + ); assert_converged(&mirror, &after); } @@ -4200,12 +4751,28 @@ mod tests { held, split(TreeAxis::Horizontal, 0.5, leaf(1), leaf(2)), )]; - diff(ws, &mut mirror, &before, Some(held), SyncScope::Full, &[]); + diff( + ws, + &mut mirror, + &before, + &[], + Some(held), + SyncScope::Full, + &[], + ); // Pane 2 dropped on the strip ahead of the tab it came from, so the tab // it becomes is desired *first*. let after = vec![tab(fresh, leaf(2)), tab(held, leaf(1))]; - let ops = diff(ws, &mut mirror, &after, Some(fresh), SyncScope::Full, &[]); + let ops = diff( + ws, + &mut mirror, + &after, + &[], + Some(fresh), + SyncScope::Full, + &[], + ); assert_eq!( ops, vec![ @@ -4240,7 +4807,15 @@ mod tests { leaf(3), ), )]; - diff(ws, &mut mirror, &before, Some(id), SyncScope::Full, &[]); + diff( + ws, + &mut mirror, + &before, + &[], + Some(id), + SyncScope::Full, + &[], + ); let after = vec![tab( id, @@ -4251,7 +4826,7 @@ mod tests { split(TreeAxis::Vertical, 0.25, leaf(3), leaf(1)), ), )]; - let ops = diff(ws, &mut mirror, &after, Some(id), SyncScope::Full, &[]); + let ops = diff(ws, &mut mirror, &after, &[], Some(id), SyncScope::Full, &[]); assert_eq!( ops, vec![ @@ -4288,7 +4863,15 @@ mod tests { split(TreeAxis::Horizontal, 0.5, leaf(3), leaf(4)), ), )]; - diff(ws, &mut mirror, &before, Some(id), SyncScope::Full, &[]); + diff( + ws, + &mut mirror, + &before, + &[], + Some(id), + SyncScope::Full, + &[], + ); // 1 and 4 trade corners: two panes moved, which no one op describes. let after = vec![tab( @@ -4300,7 +4883,7 @@ mod tests { split(TreeAxis::Horizontal, 0.5, leaf(3), leaf(1)), ), )]; - let ops = diff(ws, &mut mirror, &after, Some(id), SyncScope::Full, &[]); + let ops = diff(ws, &mut mirror, &after, &[], Some(id), SyncScope::Full, &[]); assert!( matches!(ops.first(), Some(ControlRequest::TabClose { .. })), "expected the rebuild fallback, got {ops:?}" @@ -4317,13 +4900,14 @@ mod tests { ws, &mut mirror, &[tab(id, split(TreeAxis::Vertical, 0.5, leaf(1), leaf(2)))], + &[], Some(id), SyncScope::Full, &[], ); let want = vec![tab(id, split(TreeAxis::Vertical, 0.5, leaf(1), leaf(9)))]; - let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]); + let ops = diff(ws, &mut mirror, &want, &[], Some(id), SyncScope::Full, &[]); assert_eq!( ops, vec![ControlRequest::PaneReplace { @@ -4352,13 +4936,14 @@ mod tests { ws, &mut mirror, &[tab(id, nested(0.5))], + &[], Some(id), SyncScope::Full, &[], ); let want = vec![tab(id, nested(0.7))]; - let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]); + let ops = diff(ws, &mut mirror, &want, &[], Some(id), SyncScope::Full, &[]); assert_eq!( ops, vec![ControlRequest::PaneSetRatio { @@ -4380,13 +4965,14 @@ mod tests { ws, &mut mirror, &[tab(a, leaf(1)), tab(b, leaf(2))], + &[], Some(b), SyncScope::Full, &[], ); let want = vec![tab(a, leaf(1))]; - let ops = diff(ws, &mut mirror, &want, None, SyncScope::Full, &[]); + let ops = diff(ws, &mut mirror, &want, &[], None, SyncScope::Full, &[]); assert_eq!( ops, vec![ControlRequest::TabClose { @@ -4405,10 +4991,10 @@ mod tests { let (a, b, c) = (TabId::new(), TabId::new(), TabId::new()); let mut mirror = WsMirror::default(); let before = [tab(a, leaf(1)), tab(b, leaf(2)), tab(c, leaf(3))]; - diff(ws, &mut mirror, &before, Some(c), SyncScope::Full, &[]); + diff(ws, &mut mirror, &before, &[], Some(c), SyncScope::Full, &[]); let want = vec![tab(c, leaf(3)), tab(a, leaf(1)), tab(b, leaf(2))]; - let ops = diff(ws, &mut mirror, &want, Some(c), SyncScope::Full, &[]); + let ops = diff(ws, &mut mirror, &want, &[], Some(c), SyncScope::Full, &[]); assert_eq!( ops, vec![ControlRequest::TabMove { @@ -4429,6 +5015,7 @@ mod tests { ws, &mut mirror, &[tab(id, leaf(1))], + &[], Some(id), SyncScope::Full, &[], @@ -4438,7 +5025,7 @@ mod tests { named.name = Some("build".into()); named.group = Some("/repo".into()); let want = vec![named]; - let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]); + let ops = diff(ws, &mut mirror, &want, &[], Some(id), SyncScope::Full, &[]); assert_eq!( ops, vec![ @@ -4463,9 +5050,9 @@ mod tests { let (a, b) = (TabId::new(), TabId::new()); let mut mirror = WsMirror::default(); let both = [tab(a, leaf(1)), tab(b, leaf(2))]; - diff(ws, &mut mirror, &both, Some(b), SyncScope::Full, &[]); + diff(ws, &mut mirror, &both, &[], Some(b), SyncScope::Full, &[]); - let ops = diff(ws, &mut mirror, &both, Some(a), SyncScope::Full, &[]); + let ops = diff(ws, &mut mirror, &both, &[], Some(a), SyncScope::Full, &[]); assert_eq!( ops, vec![ControlRequest::WorkspaceSetActiveTab { @@ -4490,7 +5077,7 @@ mod tests { split(TreeAxis::Vertical, 0.7, leaf(3), leaf(4)), ), )]; - let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]); + let ops = diff(ws, &mut mirror, &want, &[], Some(id), SyncScope::Full, &[]); assert_eq!( ops, vec![ @@ -4535,9 +5122,9 @@ mod tests { let id = TabId::new(); let mut mirror = WsMirror::default(); let want = vec![tab(id, split(TreeAxis::Vertical, 0.5, leaf(1), leaf(2)))]; - diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]); + diff(ws, &mut mirror, &want, &[], Some(id), SyncScope::Full, &[]); assert_eq!( - diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]), + diff(ws, &mut mirror, &want, &[], Some(id), SyncScope::Full, &[]), Vec::new() ); } @@ -4551,12 +5138,13 @@ mod tests { ws, &mut mirror, &[tab(id, leaf(1))], + &[], Some(id), SyncScope::Full, &[], ); - let ops = diff(ws, &mut mirror, &[], None, SyncScope::Full, &[id]); + let ops = diff(ws, &mut mirror, &[], &[], None, SyncScope::Full, &[id]); assert_eq!(ops, Vec::new()); assert_eq!(mirror.tabs.len(), 1, "the daemon tab survives the wait"); } @@ -4570,6 +5158,7 @@ mod tests { ws, &mut mirror, &[tab(a, leaf(1)), tab(b, leaf(2))], + &[], Some(b), SyncScope::Full, &[], @@ -4580,6 +5169,7 @@ mod tests { ws, &mut mirror, &[tab(fresh, leaf(9))], + &[], Some(fresh), SyncScope::Additive, &[], @@ -4607,6 +5197,7 @@ mod tests { id, name: None, sidebar_group: None, + project: None, root: PaneNode::Leaf { pane: 1 }, }; assert!(apply_to_mirror( @@ -4627,6 +5218,7 @@ mod tests { id, name: None, sidebar_group: None, + project: None, root: PaneNode::Split { axis: TreeAxis::Vertical, ratio: 0.5, @@ -4651,6 +5243,7 @@ mod tests { ws, &mut writer, &[tab(id, leaf(1))], + &[], Some(id), SyncScope::Full, &[], @@ -4660,6 +5253,7 @@ mod tests { ws, &mut writer, &final_state, + &[], Some(id), SyncScope::Full, &[], @@ -4693,6 +5287,7 @@ mod tests { let tab_id = TabId::new(); let ws = tty7_core::core::machine::Workspace { tabs: vec![TreeTab { + project: None, id: tab_id, name: Some("build".into()), sidebar_group: Some("/repo".into()), @@ -4791,6 +5386,7 @@ mod tests { id: tab_id, name: None, sidebar_group: None, + project: None, root: PaneNode::Leaf { pane: 7 }, }], active_tab: Some(tab_id), @@ -4820,6 +5416,7 @@ mod tests { id: TabId::new(), name: None, sidebar_group: None, + project: None, root: PaneNode::Leaf { pane: 1 }, }], active_tab: Some(TabId::new()), @@ -4837,13 +5434,14 @@ mod tests { ws, &mut mirror, &[tab(a, leaf(1)), tab(b, leaf(2))], + &[], Some(b), SyncScope::Full, &[], ); let want = vec![tab(a, leaf(2)), tab(b, leaf(2))]; - let ops = diff(ws, &mut mirror, &want, Some(b), SyncScope::Full, &[]); + let ops = diff(ws, &mut mirror, &want, &[], Some(b), SyncScope::Full, &[]); assert!( !ops.iter() .any(|op| matches!(op, ControlRequest::PaneReplace { .. })),