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 { .. })),