From cf0e8f02e6943b3a606f26efa3d587da409625c7 Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Fri, 25 Sep 2026 16:53:26 +0800 Subject: [PATCH] feat(sidebar): pinned groups above auto groups (#962) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sidebar): keep pinned groups on the workspace and derive the rest Replace the sidebar's hand-made groups with the model from #955: the sidebar groups tabs by repo automatically, and you pin what you want to keep. - Pinned groups (`PinnedGroup`: id, optional name, optional folder, fold) are stored on the workspace in the machine tree, in display order, and a tab points at one by `GroupId`. Everything else is an auto group worked out every frame and never stored: by repo home, or by `user@host` for an SSH pane — native or a shell that ssh'd onward — so `/home/ubuntu` on two machines no longer lands under one header. - A tab whose cwd *enters* a pinned folder joins it (deepest folder wins; a repo home equal to the folder counts, which keeps worktrees with their repo). It is edge-triggered through `EntryWatch`, so a tab dragged out while still inside the folder stays out until it leaves and comes back, and a tab restored at launch is not pulled in by where it already sits. - Groups sync as one `WorkspaceSetGroups` / `GroupsChanged`, pushed only from an edit and adopted from every pull, so a fresh window can never push an empty set over the workspace's. The machine hands tabs naming a dropped group back to auto grouping in the same mutation. Control dialect → v11. - Config: `sidebar_grouping` (three modes) and `sidebar_collapsed_groups` give way to one `sidebar_auto_grouping` toggle; folds live with the workspace. - `tty7 tab ls` reports the pinned group a tab is in (name or folder leaf; JSON carries id, name and folder). * feat(sidebar): draw pinned groups above a divider, with their own gestures The sidebar now reads as two halves: the groups you keep, in the order you put them, then a divider, then the groups it works out (Arc-style). - Pinned headers drag-reorder among themselves (their own reorder surface, so a pinned header cannot be dropped among the derived ones); the order lands on the workspace's group list, not on the tabs. - An auto header carried above the divider is pinned when let go. With nothing pinned yet the divider appears during that drag as a "Drop here to pin" zone, since a hairline at the top of the list is nothing to aim at. - A tab kept in a pinned group and dropped anywhere below the divider goes back to auto grouping; the divider lights to say so. - An empty pinned group stays, with a "+ New Tab" row that opens a tab in its folder (or where ⌘T would, for a label group) and files it there. - Folder groups carry a pin mark that unpins on click and a tooltip with the folder; auto headers show pin and "+" on hover. - Header menus: pinned — Rename, Set Folder… (local workspaces), Use Current Tab's Folder, Clear Folder, New Tab, Unpin (folder groups), Delete. Auto — Pin Group, New Tab. Nothing renames an auto group; nothing pins implicitly. * feat(sidebar): open folders as pinned groups from Finder, the file tree and the palette Every way into a pinned group the design calls for: - Drop a folder from Finder or Explorer onto the sidebar to pin it (a local workspace only — a dropped path is this machine's, and a folder group keeps a directory on the workspace's host). Files are let fall. - "Pin as Group" on a folder in the file tree, on local and remote workspaces alike, since the tree and the group are both on the workspace's host. - Palette "New Group" makes an empty label group and opens its name for typing; "Open Folder as Group…" picks a folder with the system picker, pins it and opens a tab in it. The picker browses this computer, so that one is not offered on a remote workspace. - Tab right-click "Move to Group" lists the pinned groups plus "New Group…", which files the tab in a fresh label group with its name open for typing. Pinning a folder already pinned hands back the group that keeps it rather than making a second one to split its tabs with. * fix(sidebar): let groups that arrive from elsewhere pull no tab into a folder A window draws its first frames before its copy of the workspace's groups lands, so every tab's entry watch recorded "in no folder" — and the groups landing then read as each tab walking into its folder. A restored tab, or one dragged out of its folder group, was pulled back in on every launch. Groups adopted from a pull or from another window's `GroupsChanged` now start every tab's watch over from where it is; only this window's own pin gathers the tabs inside the folder, and says so tab by tab. A tab also goes up with the group it names even when the window does not know that group yet, so a sync in that same gap cannot send every kept tab back to auto grouping. * docs(sidebar): describe pinned and auto groups, and log the change Rewrite the sidebar page's grouping section around "grouped by repo automatically; pin what you want to keep": the divider, folder and label groups, the edge-triggered join, every way to pin, and the header menus. The configuration reference swaps `sidebar_grouping` for `sidebar_auto_grouping`, the CLI reference describes the GROUP column as the pinned group, and the changelog gains an Unreleased entry (#955). * fix(sidebar): file a tab opened by the CLI in a pinned folder into it A tab that reaches a window as TabCreated — from `tty7 tab new` or another window — started its entry watch as a restored tab, so opening one inside a pinned folder left it in the auto group below. It is as new as a tab opened here, and now joins the folder like one; every window that hears of it reaches the same answer. * test(machine): build the group sets in their initializers Clippy's field_reassign_with_default on the two WorkspaceGroups the set-groups test assembles. * fix(sidebar): draw restored tabs in their auto group, and title by repo again Auto groups are not stored, so after a restart every tab sat in Ungrouped until its own repo probe came back, then jumped; before pinned groups the stored repo key put it in place on the first frame. Each tab now carries `last_auto`, the auto group it last resolved to, as a hint: stored with the tab, sent up alongside its group in `TabSetGroup` whenever the live answer moves, and used to draw the tab until the probe answers. The probe always wins and rewrites the hint, and the hint never outranks a pinned group or the folder-entry rule. Another window's hint only fills a gap, so two windows can never bounce a disagreement between them. The workspace's fallback title regained the repo majority it lost: the most common pinned folder first, then the repo most unpinned tabs were last filed under (a worktree counting toward its repo home), then a pane's cwd. * refactor: drop what the new sidebar left unused, and two clippy findings - `TerminalView::native_ssh_cwd` and its helper existed for the sidebar's old folder grouping of native SSH panes; an SSH tab now groups by host, and nothing else read it. - The file tree's context menu takes `cx` instead of `danger` and the new groups flag, back to the argument count it had on main. - A title test builds its workspace in the initializer. --- CHANGELOG.md | 29 + README.md | 2 +- assets/icons/pin.svg | 1 + crates/tty7-cli/src/commands.rs | 89 +- crates/tty7-cli/src/output.rs | 11 + crates/tty7-cli/src/testbed.rs | 11 +- crates/tty7-core/src/core/config.rs | 63 +- crates/tty7-core/src/core/group_key.rs | 664 +++- crates/tty7-core/src/core/machine.rs | 140 +- crates/tty7-core/src/core/session.rs | 7 +- crates/tty7-core/src/daemon/control.rs | 18 +- crates/tty7-core/src/host/server.rs | 8 +- .../tty7-server/tests/scrollback_restore.rs | 2 +- docs/cli/reference.mdx | 10 +- docs/customization/settings.mdx | 4 +- docs/reference/configuration.mdx | 2 +- docs/window/sidebar.mdx | 66 +- src/terminal/view.rs | 54 - src/ui/app.rs | 148 +- src/ui/assets.rs | 1 + src/ui/file_tree.rs | 25 +- src/ui/home.rs | 21 +- src/ui/i18n/en.rs | 25 +- src/ui/i18n/ja.rs | 25 +- src/ui/i18n/mod.rs | 19 +- src/ui/i18n/zh.rs | 25 +- src/ui/machine_mirror.rs | 146 +- src/ui/palette.rs | 6 + src/ui/reorder.rs | 130 +- src/ui/settings.rs | 40 +- src/ui/tab_sidebar.rs | 2658 +++++++++++++---- src/ui/tab_strip.rs | 47 +- src/ui/tree_sync.rs | 233 +- 33 files changed, 3615 insertions(+), 1115 deletions(-) create mode 100644 assets/icons/pin.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index b5328e8f..426b3eed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **The sidebar groups tabs by repo automatically; pin what you want to keep** + (#955). Groups now come in two halves, split by a divider. Below it, every + tab you have not pinned is filed under its git repository, and an SSH tab + under the host it is on rather than its remote path — `/home/ubuntu` on two + machines used to share a header. Above it sit the groups you keep, in the + order you drag them into, until you delete them. A pinned group can keep a + folder: a tab whose working directory enters it joins it (the deepest folder + wins when they nest, and a worktree of a pinned repo counts), while a tab you + drag out stays out until it leaves the folder and comes back. Pin an auto + group with the pin on its header or by dragging the header above the + divider; pin a folder by dropping it from Finder, with **Pin as Group** in the + Files panel, or with **Open Folder as Group…** in the palette. **New Group** + in the palette or on a tab's right-click makes a label group. Deleting a + group closes nothing — its tabs go back to auto grouping — and dragging a tab + below the divider does the same for one tab. Groups, their order and which + are folded are stored with the workspace, so every window onto it agrees. + **Settings → Window & Tabs → Auto grouping** replaces the three-way *Sidebar + grouping* choice; off, unpinned tabs sit in one flat list under the pinned + groups. Scratch is now **Ungrouped**. + - **The New Tab menu names three shells, not every one the machine has.** A stock macOS box reports nine, so the `+` menu opened on a column of `csh`, `tcsh` and `ksh` that almost nobody runs, above the SSH hosts people came for. @@ -125,6 +145,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 already sends, so a remote repository gets them unchanged. Searching the diff text itself is not part of this. +### Removed + +- **The *By repo or folder* grouping mode, "Group Automatically", and groups + stored by name.** A folder you want grouped is pinned instead, and dragging a + tab below the divider is the way back to automatic grouping. Hand-made groups + and folds from earlier versions are not carried over. The control dialect + moves to v11, so each remote host needs one Update Server, which ends the + sessions on it. + ### Fixed - **Nerd Font icons from a fallback font come out at the text's size** (#866). diff --git a/README.md b/README.md index 9754c716..74d6f4ac 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Native builds for macOS, Windows, and Linux on [**Releases**](https://github.com | | | |---|---| -| **Agent-aware** | per-pane detection (23 CLIs) · status dot · notifications · branch + diff · tray icon when input is needed · resume after reboot · tab sidebar grouped by repository | +| **Agent-aware** | per-pane detection (23 CLIs) · status dot · notifications · branch + diff · tray icon when input is needed · resume after reboot · tab sidebar grouped by repository, with pinned groups | | **CLI + Skills** | bundled `tty7` CLI · [agent skill](skills/tty7/SKILL.md) · `run` streams a command and exits with its code · `split` · `send` · `wait --until free` · `capture` | | **Editor-grade input** | ghost suggestions from history · explained tab completion · syntax highlighting · multi-line editing · click places the caret · ⌃ R fuzzy history | | **Window** | tabs & splits · ⌘ P palette · ⌘ F scrollback search · ⌘ J panel with process tree and listening ports · 13 themes, your own YAML, iTerm2 import · IME | diff --git a/assets/icons/pin.svg b/assets/icons/pin.svg new file mode 100644 index 00000000..05568e0d --- /dev/null +++ b/assets/icons/pin.svg @@ -0,0 +1 @@ + diff --git a/crates/tty7-cli/src/commands.rs b/crates/tty7-cli/src/commands.rs index d7bea737..45198d3f 100644 --- a/crates/tty7-cli/src/commands.rs +++ b/crates/tty7-cli/src/commands.rs @@ -918,12 +918,11 @@ fn tab_ls(explicit: Option<&str>, ctx: &Context, backend: &mut dyn Backend) -> R vec![ format!("@{}", resolve::ordinal_of(&machine, tab.id).unwrap_or(0)), output::tab_label(view), - // The GUI files tabs under a directory and shows its last - // segment as the heading; the full path would be the widest - // column in the table for no gain. - tab.sidebar_group - .as_deref() - .map(|g| output::path_leaf(g).to_string()) + // Only a pinned group is the workspace's to report: an auto + // group is worked out by the GUI from a probe of the tab's + // cwd, which the tree does not hold. + pinned_group_of(ws, tab) + .map(output::pinned_group_label) .unwrap_or_else(|| "-".to_string()), tab.root.pane_ids().len().to_string(), ] @@ -942,7 +941,11 @@ fn tab_ls(explicit: Option<&str>, ctx: &Context, backend: &mut dyn Backend) -> R "name": tab.name, "label": output::tab_label(view), "agent": view.agent.map(|a| a.display_name()), - "group": tab.sidebar_group, + "group": pinned_group_of(ws, tab).map(|g| json!({ + "id": g.id.to_string(), + "name": g.given_name(), + "folder": g.folder, + })), "panes": tab.root.pane_ids(), "hibernated": tab.hibernated, }) @@ -954,6 +957,16 @@ fn tab_ls(explicit: Option<&str>, ctx: &Context, backend: &mut dyn Backend) -> R ) } +/// The pinned group `tab` is filed under, when it names one the workspace +/// still has. A tab naming a deleted group is an auto tab, the same way the +/// sidebar draws it. +fn pinned_group_of<'a>( + ws: &'a tty7_core::core::machine::Workspace, + tab: &tty7_core::core::machine::Tab, +) -> Option<&'a tty7_core::core::group_key::PinnedGroup> { + ws.groups.get(tab.group?) +} + fn tab_new( explicit: Option<&str>, cwd: Option, @@ -2471,10 +2484,22 @@ mod tests { ); } + /// File tab @2 under a pinned folder group, the way the GUI would. + fn pin_second_tab(backend: &mut MockBackend, name: Option<&str>) -> String { + use tty7_core::core::group_key::PinnedGroup; + let mut group = PinnedGroup::folder(std::path::Path::new("C:\\proj\\sub")); + group.name = name.map(str::to_string); + let id = group.id; + let ws = &mut backend.machine.workspaces[0]; + ws.groups.pinned.push(group); + ws.tabs[1].group = Some(id); + id.to_string() + } + #[test] fn tab_ls_names_an_unnamed_tab_and_shows_the_leaf_of_its_group() { let mut backend = mock(); - backend.machine.workspaces[0].tabs[1].sidebar_group = Some("C:\\proj\\sub".into()); + pin_second_tab(&mut backend, None); let out = run_cli( &["tty7", "tab", "ls", "api"], @@ -2483,17 +2508,55 @@ mod tests { ); // @1 was named; @2 was not, so it borrows the leaf of its cwd. The - // GROUP column is the heading's last segment, not the whole path. + // GROUP column is what the header reads — the folder's last segment + // for a group nobody renamed, not the whole path. assert_eq!( human(out), "TAB NAME GROUP PANES\n@1 build - 1\n@2 proj sub 2\n" ); } + #[test] + fn tab_ls_shows_a_renamed_group_by_its_name() { + let mut backend = mock(); + pin_second_tab(&mut backend, Some("work")); + + let out = run_cli( + &["tty7", "tab", "ls", "api"], + &Context::default(), + &mut backend, + ); + + assert_eq!( + human(out), + "TAB NAME GROUP PANES\n@1 build - 1\n@2 proj work 2\n" + ); + } + + /// A tab naming a group the workspace no longer has is an auto tab, as + /// the sidebar draws it. + #[test] + fn tab_ls_shows_no_group_for_a_tab_naming_a_deleted_one() { + let mut backend = mock(); + pin_second_tab(&mut backend, None); + backend.machine.workspaces[0].groups.pinned.clear(); + + let out = run_cli( + &["tty7", "tab", "ls", "api"], + &Context::default(), + &mut backend, + ); + + assert_eq!( + human(out), + "TAB NAME GROUP PANES\n@1 build - 1\n@2 proj - 2\n" + ); + } + #[test] fn tab_ls_json_keeps_the_literal_name_beside_the_label() { let mut backend = mock(); - backend.machine.workspaces[0].tabs[1].sidebar_group = Some("C:\\proj\\sub".into()); + let id = pin_second_tab(&mut backend, None); let out = run_cli( &["tty7", "tab", "ls", "api"], @@ -2508,9 +2571,11 @@ mod tests { assert_eq!(tabs[1]["name"], Value::Null, "nobody named this tab"); assert_eq!(tabs[1]["label"], "proj", "the table's stand-in travels too"); assert_eq!( - tabs[1]["group"], "C:\\proj\\sub", - "the JSON keeps the whole heading the table abbreviates" + tabs[1]["group"], + json!({ "id": id, "name": null, "folder": "C:\\proj\\sub" }), + "the JSON keeps the whole folder the table abbreviates" ); + assert_eq!(tabs[0]["group"], Value::Null, "an auto tab names no group"); } #[test] diff --git a/crates/tty7-cli/src/output.rs b/crates/tty7-cli/src/output.rs index c165a3f7..85639207 100644 --- a/crates/tty7-cli/src/output.rs +++ b/crates/tty7-cli/src/output.rs @@ -47,6 +47,17 @@ fn clamp(s: &str, max: usize) -> String { /// The last segment of a path, for columns that have room for a word and not /// for a path. Both separators: the same server answers a Windows client, and /// `C:\proj` has to lose its head too. +/// What the sidebar's header for a pinned group reads: the name the user gave +/// it, or the last segment of its folder — the full path would be the widest +/// column in the table for no gain. +pub fn pinned_group_label(group: &tty7_core::core::group_key::PinnedGroup) -> String { + group + .given_name() + .map(str::to_string) + .or_else(|| group.folder.as_deref().map(|f| path_leaf(f).to_string())) + .unwrap_or_else(|| "-".to_string()) +} + pub fn path_leaf(path: &str) -> &str { let trimmed = path.trim_end_matches(['/', '\\']); match trimmed.rsplit(['/', '\\']).next() { diff --git a/crates/tty7-cli/src/testbed.rs b/crates/tty7-cli/src/testbed.rs index a24aaf75..84482064 100644 --- a/crates/tty7-cli/src/testbed.rs +++ b/crates/tty7-cli/src/testbed.rs @@ -10,14 +10,16 @@ pub fn two_workspace_machine() -> Machine { Tab { id: TabId::new(), name: Some("build".into()), - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Leaf { pane: 1 }, hibernated: false, }, Tab { id: TabId::new(), name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Split { axis: Axis::Horizontal, ratio: 0.5, @@ -29,6 +31,7 @@ pub fn two_workspace_machine() -> Machine { ], active_tab: None, attachment: None, + groups: Default::default(), }; let web = Workspace { id: WorkspaceId::new(), @@ -37,12 +40,14 @@ pub fn two_workspace_machine() -> Machine { tabs: vec![Tab { id: TabId::new(), name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Leaf { pane: 5 }, hibernated: false, }], active_tab: None, attachment: None, + groups: Default::default(), }; let record = |id: u64, cwd: &str| PaneRecord { id, diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index b277f52f..5c6b9a18 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -268,24 +268,12 @@ pub struct Config { /// [`Self::editor_soft_wrap`]. Files that are not Markdown ignore it. #[serde(default)] pub editor_markdown_preview: bool, - #[serde(default, deserialize_with = "de_lenient")] - pub sidebar_grouping: SidebarGrouping, - /// Which sidebar groups are folded shut, by group key: the repo root the - /// group is named after, or the empty string for the scratch group, which - /// has no root of its own and no real key can ever collide with. - /// - /// Kept as a list of the folded ones rather than a flag per group because - /// groups come and go with the tabs — a group nobody has opened yet has to - /// start expanded, and an entry for a repo that is no longer around costs - /// one dead path in the file. - /// - /// `String`, not `PathBuf`: serde refuses to serialize a non-UTF-8 - /// `PathBuf`, and `Config::save` turns that refusal into one `warn!` and - /// a return — so a single repo root with odd bytes in it would silently - /// stop the *whole* config being written from then on. A lossy spelling - /// of such a root at worst folds two of them together. - #[serde(default, deserialize_with = "de_lenient")] - pub sidebar_collapsed_groups: Vec, + /// Whether the sidebar files tabs nobody pinned into groups of its own — + /// by repository, and by host for an SSH pane. Off, those tabs sit in one + /// flat list below the pinned groups, which are the user's and show + /// either way. + #[serde(default = "default_true")] + pub sidebar_auto_grouping: bool, #[serde(default = "default_true")] pub sidebar_diff_preview: bool, #[serde(default, deserialize_with = "de_lenient")] @@ -597,17 +585,6 @@ pub enum WindowBackdrop { Off, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] -#[serde(rename_all = "kebab-case")] -pub enum SidebarGrouping { - #[default] - Repo, - /// By repository where there is one; a tab whose cwd is known not to be - /// in a repo groups under that cwd instead of falling to Scratch. - RepoOrDirectory, - None, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum NotifyMode { @@ -770,8 +747,7 @@ impl Default for Config { scm_changes_tree: false, editor_soft_wrap: false, editor_markdown_preview: false, - sidebar_grouping: SidebarGrouping::Repo, - sidebar_collapsed_groups: Vec::new(), + sidebar_auto_grouping: true, sidebar_diff_preview: true, notify_on_command_finish: NotifyMode::Unfocused, check_for_updates: true, @@ -1778,24 +1754,13 @@ mod tests { } #[test] - fn sidebar_grouping_defaults_and_round_trips_leniently() { - assert_eq!(Config::default().sidebar_grouping, SidebarGrouping::Repo); - - let text = serde_json::to_string(&Config { - sidebar_grouping: SidebarGrouping::RepoOrDirectory, - ..Config::default() - }) - .unwrap(); - assert!(text.contains("\"sidebar_grouping\":\"repo-or-directory\"")); - let back: Config = serde_json::from_str(&text).unwrap(); - assert_eq!(back.sidebar_grouping, SidebarGrouping::RepoOrDirectory); - - let flat: Config = serde_json::from_str(r#"{"sidebar_grouping":"none"}"#).unwrap(); - assert_eq!(flat.sidebar_grouping, SidebarGrouping::None); - - // Unknown values fall back to Repo instead of rejecting the whole config. - let lenient: Config = serde_json::from_str(r#"{"sidebar_grouping":"folders"}"#).unwrap(); - assert_eq!(lenient.sidebar_grouping, SidebarGrouping::Repo); + fn sidebar_auto_grouping_defaults_on_and_round_trips() { + assert!(Config::default().sidebar_auto_grouping); + let off: Config = serde_json::from_str(r#"{"sidebar_auto_grouping":false}"#).unwrap(); + assert!(!off.sidebar_auto_grouping); + let json = serde_json::to_string(&off).unwrap(); + let back: Config = serde_json::from_str(&json).unwrap(); + assert!(!back.sidebar_auto_grouping, "persisted"); } #[test] diff --git a/crates/tty7-core/src/core/group_key.rs b/crates/tty7-core/src/core/group_key.rs index 5992fbc3..99a8f0b4 100644 --- a/crates/tty7-core/src/core/group_key.rs +++ b/crates/tty7-core/src/core/group_key.rs @@ -1,156 +1,610 @@ -//! Which sidebar group a tab belongs to, and where that answer came from. +//! Which sidebar group a tab belongs to, and who decided. //! -//! Two kinds of group live in the same sidebar and look alike, but they are -//! owned by different things. A [`GroupKey::Repo`] is *derived*: the sidebar -//! recomputes it every frame from the tab's cwd, so a tab that `cd`s into -//! another repository walks into another group on its own. A -//! [`GroupKey::Custom`] is *stated*: the user put the tab there by hand, and -//! nothing about the cwd may move it out again. +//! "The sidebar groups tabs by repo automatically. Pin what you want to keep." +//! Everything here follows from that sentence. Groups above the divider are +//! *kept*: a [`PinnedGroup`] is stored with the workspace, has an id, an order +//! the user chose and a name they may change, and a tab in one only ever +//! leaves by hand. Everything below the divider is *derived*: an [`AutoKey`] +//! is worked out from the tab's cwd (or, for an SSH pane, from the host it is +//! on) every time the sidebar is drawn, so a tab that `cd`s into another +//! repository walks into another group on its own, and a group whose last tab +//! leaves simply stops existing. Nothing about an auto group is stored except +//! whether it is folded. //! -//! Keeping the two in one enum is what makes the "never overwrite a stated -//! group" rule enforceable — the alternative, a path plus a `pinned` flag, -//! leaves the flag and the path free to disagree, and a custom group's name -//! is not a path in the first place. +//! A pinned group may name a folder. That is what lets a group be kept for a +//! project while its tabs come and go: a tab whose cwd *enters* the folder +//! joins it (see [`EntryWatch`]), and an empty folder group stays on screen +//! with a row to open a tab in it. A pinned group without a folder is a plain +//! label the user files tabs under by hand. use std::path::{Path, PathBuf}; -/// The prefix that marks a custom group in the flat string spelling. No -/// absolute path can collide with it: a POSIX root starts with `/` and a -/// Windows one with a single-letter drive, so neither can begin `custom:`. -const CUSTOM: &str = "custom:"; +use serde::{Deserialize, Serialize}; +/// The identity of a pinned group. A name cannot be one — two groups may share +/// a name, and a rename must not orphan every tab filed under the old one — +/// so tabs point at this instead. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct GroupId(uuid::Uuid); + +impl GroupId { + pub fn new() -> Self { + Self(uuid::Uuid::new_v4()) + } +} + +impl Default for GroupId { + fn default() -> Self { + Self::new() + } +} + +impl std::fmt::Display for GroupId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +/// A group the user chose to keep. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PinnedGroup { + pub id: GroupId, + /// What the header reads. `None` falls back to the folder's last + /// component, so pinning `~/src/tty7` reads `tty7` until someone renames + /// it — and renaming it back to nothing brings that back. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// A directory on the workspace's own host, or `None` for a label group. + /// + /// A `String`, not a `PathBuf`, for the same reason `PaneRecord::cwd` is + /// one: serde refuses to write a non-UTF-8 `PathBuf`, and the machine tree + /// is written whole — one odd byte in one folder would stop every layout + /// on the machine being saved. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub folder: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub collapsed: bool, +} + +impl PinnedGroup { + /// A group for tabs the user files by hand, called `name`. + pub fn label(name: impl Into) -> Self { + Self { + id: GroupId::new(), + name: Some(name.into()), + folder: None, + collapsed: false, + } + } + + /// A group that keeps `folder`, named after it until renamed. + pub fn folder(folder: &Path) -> Self { + Self { + id: GroupId::new(), + name: None, + folder: Some(folder.to_string_lossy().into_owned()), + collapsed: false, + } + } + + pub fn folder_path(&self) -> Option<&Path> { + self.folder.as_deref().map(Path::new) + } + + /// The name the user typed, when there is one worth printing. A blank + /// name is no name: it would draw an unlabelled header. + pub fn given_name(&self) -> Option<&str> { + self.name + .as_deref() + .map(str::trim) + .filter(|n| !n.is_empty()) + } +} + +/// A group the sidebar works out for itself. Never stored as a membership — +/// only as the key a fold is remembered under. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(from = "AutoKeyWire", into = "AutoKeyWire")] +pub enum AutoKey { + /// The repository home a tab's cwd resolved to. A linked worktree resolves + /// to the repo it belongs to, and a submodule to itself — so a submodule + /// gets its own group, and a worktree sits with its main checkout. + Repo(PathBuf), + /// The host an SSH pane is on, as its `user@host` target. + /// + /// By host and not by remote path: two machines' `/home/ubuntu` are not + /// the same directory, and grouping by the path alone filed tabs on two + /// different boxes under one header. + SshHost(String), +} + +/// The spelling [`AutoKey`] is stored in. A repo root goes down as a lossy +/// string for the reason [`PinnedGroup::folder`] is one. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum AutoKeyWire { + Repo(String), + SshHost(String), +} + +impl From for AutoKey { + fn from(w: AutoKeyWire) -> Self { + match w { + AutoKeyWire::Repo(p) => AutoKey::Repo(PathBuf::from(p)), + AutoKeyWire::SshHost(h) => AutoKey::SshHost(h), + } + } +} + +impl From for AutoKeyWire { + fn from(k: AutoKey) -> Self { + match k { + AutoKey::Repo(p) => AutoKeyWire::Repo(p.to_string_lossy().into_owned()), + AutoKey::SshHost(h) => AutoKeyWire::SshHost(h), + } + } +} + +/// Where a tab is drawn: a pinned group, an auto group, or — as `None` beside +/// it — Ungrouped. #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] pub enum GroupKey { - /// The repository (or, under `RepoOrDirectory`, the plain directory) the - /// sidebar worked out for itself. Recomputed from the cwd every frame. - Repo(PathBuf), - /// A group the user named and put this tab in. Never recomputed. - Custom(String), + Pinned(GroupId), + Auto(AutoKey), } impl GroupKey { - /// The custom group called `name`, or `None` when the name is blank — - /// an empty name would print as an unlabelled header and collide with - /// Scratch, which already owns "no name at all". - pub fn custom(name: &str) -> Option { - let name = name.trim(); - (!name.is_empty()).then(|| Self::Custom(name.to_string())) + pub fn is_pinned(&self) -> bool { + matches!(self, Self::Pinned(_)) } - pub fn is_custom(&self) -> bool { - matches!(self, Self::Custom(_)) - } - - pub fn repo_root(&self) -> Option<&Path> { + pub fn pinned(&self) -> Option { match self { - Self::Repo(p) => Some(p), - Self::Custom(_) => None, + Self::Pinned(id) => Some(*id), + Self::Auto(_) => None, } } - /// The flat spelling used everywhere a group has to survive as one - /// string: the control protocol, the session file, and the list of - /// folded groups in the config. - pub fn encode(&self) -> String { + pub fn auto(&self) -> Option<&AutoKey> { match self { - // Lossy on purpose. `Config::save` turns a serde failure on a - // non-UTF-8 `PathBuf` into one `warn!` and a return, so a single - // repo root with odd bytes in it would silently stop the whole - // config being written from then on. Two such roots folding - // together is the cheaper failure by a wide margin. - Self::Repo(p) => p.to_string_lossy().into_owned(), - Self::Custom(name) => format!("{CUSTOM}{name}"), - } - } - - /// Reads back [`GroupKey::encode`]. Anything without the marker is a - /// repo root, which is also what every group written before custom - /// groups existed decodes to. - pub fn decode(s: &str) -> Option { - match s.strip_prefix(CUSTOM) { - Some(name) => Self::custom(name), - None => (!s.is_empty()).then(|| Self::Repo(PathBuf::from(s))), + Self::Auto(key) => Some(key), + Self::Pinned(_) => None, } } } -impl serde::Serialize for GroupKey { - fn serialize(&self, ser: S) -> Result { - ser.serialize_str(&self.encode()) +/// Everything about a workspace's sidebar groups that outlives a frame, stored +/// on the workspace in the machine tree so every window onto it agrees. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceGroups { + /// In display order. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub pinned: Vec, + /// The auto groups folded shut. A list of the folded ones rather than a + /// flag per group because auto groups come and go with the tabs — one + /// nobody has seen yet has to start open. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub auto_collapsed: Vec, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub ungrouped_collapsed: bool, +} + +impl WorkspaceGroups { + pub fn is_empty(&self) -> bool { + *self == Self::default() + } + + pub fn get(&self, id: GroupId) -> Option<&PinnedGroup> { + self.pinned.iter().find(|g| g.id == id) + } + + pub fn get_mut(&mut self, id: GroupId) -> Option<&mut PinnedGroup> { + self.pinned.iter_mut().find(|g| g.id == id) + } + + pub fn contains(&self, id: GroupId) -> bool { + self.get(id).is_some() + } + + /// Whether the group drawn under `key` is folded; `None` is Ungrouped. + pub fn is_folded(&self, key: Option<&GroupKey>) -> bool { + match key { + Some(GroupKey::Pinned(id)) => self.get(*id).is_some_and(|g| g.collapsed), + Some(GroupKey::Auto(auto)) => self.auto_collapsed.contains(auto), + None => self.ungrouped_collapsed, + } + } + + pub fn toggle_folded(&mut self, key: Option<&GroupKey>) { + match key { + Some(GroupKey::Pinned(id)) => { + if let Some(g) = self.get_mut(*id) { + g.collapsed = !g.collapsed; + } + } + Some(GroupKey::Auto(auto)) => { + match self.auto_collapsed.iter().position(|k| k == auto) { + Some(at) => { + self.auto_collapsed.remove(at); + } + None => self.auto_collapsed.push(auto.clone()), + } + } + None => self.ungrouped_collapsed = !self.ungrouped_collapsed, + } + } + + /// The pinned folder group a cwd is inside, if any — see + /// [`pinned_folder_for`]. + pub fn folder_for(&self, cwd: Option<&Path>, repo_home: Option<&Path>) -> Option { + pinned_folder_for(&self.pinned, cwd, repo_home) } } -impl<'de> serde::Deserialize<'de> for GroupKey { - fn deserialize>(de: D) -> Result { - let s = String::deserialize(de)?; - Self::decode(&s).ok_or_else(|| serde::de::Error::custom("empty sidebar group key")) +/// The pinned folder group a tab sitting in `cwd` (whose repo home, when it is +/// in one, is `repo_home`) belongs in. +/// +/// A folder counts when the cwd is inside it, or when the cwd's repo home *is* +/// it: a linked worktree usually lives outside the checkout it was made from +/// (`~/wt/feature` for `~/src/tty7`), and pinning the repo is a statement about +/// all of its checkouts. When folders nest the deepest one wins, so pinning a +/// monorepo root and one package inside it files the package's tabs under the +/// package. Depth is counted in path components; two folders at the same depth +/// cannot both contain one cwd unless they are the same folder, and then the +/// first in the list wins. +pub fn pinned_folder_for( + pinned: &[PinnedGroup], + cwd: Option<&Path>, + repo_home: Option<&Path>, +) -> Option { + let mut best: Option<(usize, GroupId)> = None; + for group in pinned { + let Some(folder) = group.folder_path() else { + continue; + }; + let inside = cwd.is_some_and(|c| c.starts_with(folder)) || repo_home == Some(folder); + if !inside { + continue; + } + let depth = folder.components().count(); + if best.is_none_or(|(d, _)| depth > d) { + best = Some((depth, group.id)); + } } + best.map(|(_, id)| id) } -/// How a group is named in `Config::sidebar_collapsed_groups`. The scratch -/// group has no key of its own, so it is written as the empty string — which -/// neither a repo root nor a custom group can ever be. -pub fn collapse_key(key: Option<&GroupKey>) -> String { - key.map(GroupKey::encode).unwrap_or_default() +/// The auto group a tab resolves to, from what is known about it right now. +/// +/// `ssh_host` is the target of an SSH pane — one whose shell runs on a machine +/// no `Host` of ours reaches, so there is no repo to probe and its cwd names a +/// directory on a box the other tabs are not on. It outranks everything: the +/// host is the only thing about such a tab that is certain. +/// +/// Otherwise `known` is the repo cache's three-valued answer for the tab's cwd: +/// `Some(Some(home))` a repo, `Some(None)` a directory that is known not to be +/// in one (Ungrouped), and `None` a probe that has not landed. That last one is +/// no decision at all, answered as `None`, so the caller keeps whatever it had +/// rather than bouncing the tab through Ungrouped mid-probe. +pub fn auto_key(ssh_host: Option<&str>, known: Option>) -> Option> { + if let Some(host) = ssh_host.map(str::trim).filter(|h| !h.is_empty()) { + return Some(Some(AutoKey::SshHost(host.to_string()))); + } + Some(known?.map(AutoKey::Repo)) +} + +/// Where a tab is drawn, given the pinned group it names (if any), the auto +/// group it resolved to, and whether auto grouping is on. +/// +/// A tab naming a group that no longer exists is an auto tab: deleting a +/// group returns its tabs to auto grouping, and a window that hears of the +/// deletion before it hears of the tabs being cleared must already draw them +/// that way. +pub fn place( + stated: Option, + groups: &WorkspaceGroups, + auto_grouping: bool, + auto: Option, +) -> Option { + if let Some(id) = stated.filter(|id| groups.contains(*id)) { + return Some(GroupKey::Pinned(id)); + } + if !auto_grouping { + return None; + } + auto.map(GroupKey::Auto) +} + +/// Edge detection for rule 2: a tab joins a pinned folder when its cwd +/// *enters* the folder, not whenever it happens to be inside. +/// +/// The difference is the user's say. A tab dragged out of a folder group while +/// it is still sitting in the folder has been told where to go; re-applying +/// "inside means member" on the next frame would put it straight back. So the +/// watch remembers which pinned folder the tab was last seen in, and only a +/// change of answer — outside to inside, or one folder to another — counts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct EntryWatch { + /// `None` until the tab is first looked at; then the folder it was in. + last: Option>, +} + +impl EntryWatch { + /// For a tab that already existed when this window met it — restored from + /// the machine tree at launch, or on a pull. The first look only + /// records where it is: whatever it is sitting in, it was already there, + /// and whoever last had it decided its group. + pub fn baseline() -> Self { + Self { last: None } + } + + /// For a tab that was just opened, here or by another client. It starts + /// outside every folder, so the first cwd it reports inside one is an + /// entry — which is what makes a tab opened in a pinned folder land in + /// that group. + pub fn fresh() -> Self { + Self { last: Some(None) } + } + + /// Records that the tab is now in `now` (the deepest pinned folder + /// containing it, if any), and answers the folder it just entered. + pub fn observe(&mut self, now: Option) -> Option { + let entered = match self.last { + Some(before) => now.filter(|g| before != Some(*g)), + None => None, + }; + self.last = Some(now); + entered + } } #[cfg(test)] mod tests { use super::*; - #[test] - fn a_repo_root_round_trips_as_a_bare_path() { - let k = GroupKey::Repo(PathBuf::from("/home/u/tty7")); - assert_eq!(k.encode(), "/home/u/tty7"); - assert_eq!(GroupKey::decode("/home/u/tty7"), Some(k)); + fn p(s: &str) -> PathBuf { + PathBuf::from(s) } - #[test] - fn a_custom_group_round_trips_under_its_marker() { - let k = GroupKey::custom("工作").expect("non-blank"); - assert_eq!(k.encode(), "custom:工作"); - assert_eq!(GroupKey::decode("custom:工作"), Some(k)); + fn folders(paths: &[&str]) -> Vec { + paths + .iter() + .map(|f| PinnedGroup::folder(Path::new(f))) + .collect() } - /// The whole point of the marker. A custom group named after something - /// that looks like a path must not come back as a repo root, or folding - /// one would fold the other. + /// Pinning a monorepo root and a package in it: the package's tabs go to + /// the package, the rest of the repo's to the root. #[test] - fn a_custom_group_named_like_a_path_stays_custom() { - let k = GroupKey::custom("/home/u/tty7").expect("non-blank"); - assert_eq!(k.encode(), "custom:/home/u/tty7"); - assert_eq!(GroupKey::decode(&k.encode()), Some(k.clone())); - assert!(k.is_custom()); - assert_ne!(k, GroupKey::Repo(PathBuf::from("/home/u/tty7"))); + fn nested_pinned_folders_file_a_tab_under_the_deepest() { + let pinned = folders(&["/w/mono", "/w/mono/pkg/app", "/w/mono/pkg"]); + let at = |cwd: &str| pinned_folder_for(&pinned, Some(Path::new(cwd)), None); + assert_eq!(at("/w/mono/pkg/app/src"), Some(pinned[1].id)); + assert_eq!(at("/w/mono/pkg/lib"), Some(pinned[2].id)); + assert_eq!(at("/w/mono/docs"), Some(pinned[0].id)); + assert_eq!(at("/w/other"), None); } - /// Every group written before custom groups existed is a bare path. + /// Order in the list says nothing about depth — the deepest wins even + /// when it was pinned first. #[test] - fn an_old_session_key_decodes_as_a_repo() { + fn the_deepest_folder_wins_whichever_was_pinned_first() { + let pinned = folders(&["/w/mono/pkg", "/w/mono"]); assert_eq!( - GroupKey::decode("/w/alpha"), - Some(GroupKey::Repo(PathBuf::from("/w/alpha"))) + pinned_folder_for(&pinned, Some(Path::new("/w/mono/pkg/x")), None), + Some(pinned[0].id) + ); + } + + /// Component-wise, not by string prefix: `/w/tty7-old` is not inside + /// `/w/tty7`. + #[test] + fn a_sibling_sharing_a_prefix_is_not_inside() { + let pinned = folders(&["/w/tty7"]); + assert_eq!( + pinned_folder_for(&pinned, Some(Path::new("/w/tty7-old")), None), + None + ); + } + + /// A worktree lives outside the checkout it came from, but its repo home + /// is that checkout — pinning the repo keeps its worktrees too. + #[test] + fn a_worktree_joins_the_folder_its_repo_home_is_pinned_as() { + let pinned = folders(&["/w/tty7"]); + assert_eq!( + pinned_folder_for( + &pinned, + Some(Path::new("/tmp/wt/feature/src")), + Some(Path::new("/w/tty7")) + ), + Some(pinned[0].id) + ); + } + + /// The repo home counts as the folder itself, so a deeper folder the cwd + /// is inside still wins over it. + #[test] + fn a_deeper_folder_beats_a_repo_home_match() { + let pinned = folders(&["/w/tty7", "/tmp/wt/feature"]); + assert_eq!( + pinned_folder_for( + &pinned, + Some(Path::new("/tmp/wt/feature/src")), + Some(Path::new("/w/tty7")) + ), + Some(pinned[1].id) + ); + } + + /// A label group has no folder, so no cwd is ever inside it. + #[test] + fn a_label_group_never_pulls_a_tab_in() { + let pinned = vec![PinnedGroup::label("work")]; + assert_eq!(pinned_folder_for(&pinned, Some(Path::new("/")), None), None); + } + + #[test] + fn a_fresh_tab_entering_a_folder_joins_it() { + let g = GroupId::new(); + let mut watch = EntryWatch::fresh(); + assert_eq!(watch.observe(None), None); + assert_eq!(watch.observe(Some(g)), Some(g), "outside to inside"); + assert_eq!(watch.observe(Some(g)), None, "staying inside is no entry"); + } + + #[test] + fn a_fresh_tab_opened_inside_a_folder_joins_on_its_first_look() { + let g = GroupId::new(); + let mut watch = EntryWatch::fresh(); + assert_eq!(watch.observe(Some(g)), Some(g)); + } + + /// A tab this window met already in place keeps the group whoever had it + /// gave it — sitting in a folder is not the same as walking into it. + #[test] + fn a_restored_tab_is_not_pulled_in_by_where_it_already_is() { + let g = GroupId::new(); + let mut watch = EntryWatch::baseline(); + assert_eq!(watch.observe(Some(g)), None); + assert_eq!(watch.observe(None), None); + assert_eq!(watch.observe(Some(g)), Some(g), "but walking back in is"); + } + + /// Dragged out while inside: not pulled back until it leaves and comes + /// back. The watch is fed whether or not the tab is in a group, so it + /// already knows the tab is inside when the drag lands. + #[test] + fn a_tab_dragged_out_is_not_pulled_back_until_it_re_enters() { + let g = GroupId::new(); + let mut watch = EntryWatch::fresh(); + assert_eq!(watch.observe(Some(g)), Some(g), "joined on the way in"); + // … dragged out here; the cwd has not moved … + assert_eq!(watch.observe(Some(g)), None, "still inside: stays out"); + assert_eq!(watch.observe(Some(g)), None); + assert_eq!(watch.observe(None), None, "left"); + assert_eq!(watch.observe(Some(g)), Some(g), "re-entered: joins again"); + } + + #[test] + fn moving_from_one_folder_into_another_is_an_entry() { + let (a, b) = (GroupId::new(), GroupId::new()); + let mut watch = EntryWatch::fresh(); + watch.observe(Some(a)); + assert_eq!(watch.observe(Some(b)), Some(b)); + } + + /// The same remote path on two machines is two directories. + #[test] + fn ssh_tabs_on_two_hosts_with_one_path_land_apart() { + let one = auto_key(Some("ubuntu@alpha"), Some(Some(p("/home/ubuntu")))); + let two = auto_key(Some("ubuntu@beta"), Some(Some(p("/home/ubuntu")))); + assert_eq!(one, Some(Some(AutoKey::SshHost("ubuntu@alpha".into())))); + assert_eq!(two, Some(Some(AutoKey::SshHost("ubuntu@beta".into())))); + assert_ne!(one, two); + } + + #[test] + fn a_probe_that_has_not_landed_is_no_decision() { + assert_eq!(auto_key(None, None), None); + assert_eq!(auto_key(None, Some(None)), Some(None), "known: no repo"); + assert_eq!( + auto_key(None, Some(Some(p("/w/r")))), + Some(Some(AutoKey::Repo(p("/w/r")))) ); } #[test] - fn a_blank_name_is_no_group_at_all() { - assert_eq!(GroupKey::custom(" "), None); - assert_eq!(GroupKey::decode("custom:"), None); - assert_eq!(GroupKey::decode(""), None); - } - - #[test] - fn a_name_is_trimmed_before_it_becomes_a_key() { - assert_eq!(GroupKey::custom(" work "), GroupKey::custom("work")); - } - - #[test] - fn scratch_collapses_under_the_empty_string() { - assert_eq!(collapse_key(None), ""); + fn a_tab_in_a_pinned_group_is_drawn_there_whatever_its_cwd_says() { + let mut groups = WorkspaceGroups::default(); + let work = PinnedGroup::label("work"); + let id = work.id; + groups.pinned.push(work); + let repo = Some(AutoKey::Repo(p("/w/r"))); assert_eq!( - collapse_key(Some(&GroupKey::Repo(PathBuf::from("/w/a")))), - "/w/a" + place(Some(id), &groups, true, repo.clone()), + Some(GroupKey::Pinned(id)) ); + assert_eq!( + place(None, &groups, true, repo), + Some(GroupKey::Auto(AutoKey::Repo(p("/w/r")))) + ); + } + + /// Deleting a group returns its tabs to auto grouping — even in a window + /// that has heard of the deletion and not yet of the tabs being cleared. + #[test] + fn a_tab_naming_a_deleted_group_is_filed_automatically() { + let groups = WorkspaceGroups::default(); + let repo = Some(AutoKey::Repo(p("/w/r"))); + assert_eq!( + place(Some(GroupId::new()), &groups, true, repo), + Some(GroupKey::Auto(AutoKey::Repo(p("/w/r")))) + ); + } + + /// Auto grouping off: no auto group, but a pinned one still stands. + #[test] + fn auto_grouping_off_keeps_pinned_groups_and_drops_the_rest() { + let mut groups = WorkspaceGroups::default(); + let work = PinnedGroup::label("work"); + let id = work.id; + groups.pinned.push(work); + let repo = Some(AutoKey::Repo(p("/w/r"))); + assert_eq!(place(None, &groups, false, repo.clone()), None); + assert_eq!( + place(Some(id), &groups, false, repo), + Some(GroupKey::Pinned(id)) + ); + } + + #[test] + fn folds_are_kept_per_kind_of_group() { + let mut groups = WorkspaceGroups::default(); + let work = PinnedGroup::label("work"); + let id = work.id; + groups.pinned.push(work); + let repo = GroupKey::Auto(AutoKey::Repo(p("/w/r"))); + let pinned = GroupKey::Pinned(id); + for key in [Some(&repo), Some(&pinned), None] { + assert!(!groups.is_folded(key)); + groups.toggle_folded(key); + assert!(groups.is_folded(key)); + } + assert_eq!(groups.auto_collapsed, vec![AutoKey::Repo(p("/w/r"))]); + assert!(groups.pinned[0].collapsed); + assert!(groups.ungrouped_collapsed); + groups.toggle_folded(Some(&repo)); + assert!(groups.auto_collapsed.is_empty(), "unfolding takes it out"); + } + + #[test] + fn groups_round_trip_and_an_empty_set_writes_nothing() { + assert_eq!( + serde_json::to_string(&WorkspaceGroups::default()).unwrap(), + "{}" + ); + let mut groups = WorkspaceGroups::default(); + groups + .pinned + .push(PinnedGroup::folder(Path::new("/w/tty7"))); + groups.pinned.push(PinnedGroup::label("工作")); + groups.auto_collapsed.push(AutoKey::SshHost("u@h".into())); + groups.auto_collapsed.push(AutoKey::Repo(p("/w/r"))); + let text = serde_json::to_string(&groups).unwrap(); + assert!(text.contains(r#"{"repo":"/w/r"}"#), "{text}"); + let back: WorkspaceGroups = serde_json::from_str(&text).unwrap(); + assert_eq!(back, groups); + } + + #[test] + fn a_blank_name_falls_back_to_the_folder() { + let mut g = PinnedGroup::folder(Path::new("/w/tty7")); + assert_eq!(g.given_name(), None); + g.name = Some(" ".into()); + assert_eq!(g.given_name(), None); + g.name = Some(" tty ".into()); + assert_eq!(g.given_name(), Some("tty")); } } diff --git a/crates/tty7-core/src/core/machine.rs b/crates/tty7-core/src/core/machine.rs index 201c0141..7040d3ab 100644 --- a/crates/tty7-core/src/core/machine.rs +++ b/crates/tty7-core/src/core/machine.rs @@ -7,6 +7,7 @@ use std::time::Duration; use serde::{Deserialize, Serialize}; use crate::core::cli_agent::CLIAgent; +use crate::core::group_key::{AutoKey, GroupId, WorkspaceGroups}; use crate::core::session::WorkspaceId; use crate::daemon::protocol::{NativeSshSpec, ShellSpec}; @@ -134,6 +135,11 @@ pub struct Workspace { /// one read back at boot would name a holder that no longer exists. #[serde(default, skip_serializing_if = "Option::is_none")] pub attachment: Option, + /// The workspace's pinned sidebar groups and which groups are folded. + /// Kept here, beside the tabs that point into it, so every window onto + /// the workspace draws the same groups in the same order. + #[serde(default, skip_serializing_if = "WorkspaceGroups::is_empty")] + pub groups: WorkspaceGroups, } impl Default for Workspace { @@ -145,6 +151,7 @@ impl Default for Workspace { tabs: Vec::new(), active_tab: None, attachment: None, + groups: WorkspaceGroups::default(), } } } @@ -155,8 +162,20 @@ pub struct Tab { pub id: TabId, #[serde(default)] pub name: Option, - #[serde(default)] - pub sidebar_group: Option, + /// The pinned group this tab was put in, or `None` for one the sidebar + /// files by itself. Only pinned groups are stored: an auto group is + /// worked out from the tab's cwd every time it is drawn. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub group: Option, + /// The auto group this tab last resolved to — a hint, not a membership. + /// + /// Auto groups are worked out from a repo probe, and at launch no probe + /// has answered yet: without this every restored tab sat in Ungrouped + /// until its own came back, then jumped. The GUI draws the tab here until + /// the live answer lands, and the live answer always wins and rewrites + /// it. It never outranks `group`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_auto: Option, pub root: PaneNode, /// The tab was put to sleep: its panes were stopped to give their memory /// back, and it stays in the workspace to be woken later (#762). @@ -178,7 +197,8 @@ impl Tab { Tab { id: TabId::new(), name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Leaf { pane }, hibernated: false, } @@ -442,7 +462,12 @@ pub enum LayoutDelta { }, TabRegrouped { tab: TabId, - group: Option, + group: Option, + #[serde(default)] + last_auto: Option, + }, + GroupsChanged { + groups: WorkspaceGroups, }, TabRestructured { tab: Tab, @@ -802,19 +827,68 @@ impl MachineStore { &self, workspace: WorkspaceId, tab: TabId, - group: Option, + group: Option, + last_auto: Option, origin: Option, ) -> io::Result<()> { self.mutate(origin, |m| { let t = find_tab(m, workspace, tab)?; - t.sidebar_group = group.clone(); + t.group = group; + t.last_auto = last_auto.clone(); Ok(( (), - vec![(workspace, LayoutDelta::TabRegrouped { tab, group })], + vec![( + workspace, + LayoutDelta::TabRegrouped { + tab, + group, + last_auto, + }, + )], )) }) } + /// Replaces the workspace's sidebar groups whole. + /// + /// Whole rather than one verb per edit: the set is small, edited by hand, + /// and every edit to it — a pin, a rename, a drag that reorders — is + /// "this is the list now". Last writer wins, which for two windows racing + /// to fold the same header is the answer either of them would have given. + /// + /// A tab still pointing at a group this drops is handed back to auto + /// grouping here, in the same mutation, so the tree never holds a tab + /// filed under a group nobody can see. Each such tab is announced like any + /// other regroup; the client that sent the new set is left out of those + /// deltas as usual and has already cleared the tabs itself. + pub fn workspace_set_groups( + &self, + workspace: WorkspaceId, + groups: WorkspaceGroups, + origin: Option, + ) -> io::Result<()> { + self.mutate(origin, |m| { + let ws = find_workspace(m, workspace)?; + let mut deltas = Vec::new(); + for t in &mut ws.tabs { + if t.group.is_some_and(|g| !groups.contains(g)) { + t.group = None; + deltas.push(( + workspace, + LayoutDelta::TabRegrouped { + tab: t.id, + group: None, + last_auto: t.last_auto.clone(), + }, + )); + } + } + ws.groups = groups.clone(); + deltas.push((workspace, LayoutDelta::GroupsChanged { groups })); + Ok(((), deltas)) + }) + } + pub fn pane_split( &self, workspace: WorkspaceId, @@ -2107,17 +2181,63 @@ mod tests { store .tab_rename(ws, first.id, Some("build".into()), None) .unwrap(); + let group = GroupId::new(); store - .tab_set_group(ws, first.id, Some("/repo/tty7".into()), None) + .tab_set_group(ws, first.id, Some(group), None, None) .unwrap(); store.tab_move(ws, first.id, 1, None).unwrap(); let workspace = store.workspace(ws).unwrap(); assert_eq!(workspace.tabs[0].id, second.id); assert_eq!(workspace.tabs[1].name.as_deref(), Some("build")); + assert_eq!(workspace.tabs[1].group, Some(group)); + } + + /// Dropping a group hands its tabs back to auto grouping in the same + /// mutation, and says so — a window that only heard `GroupsChanged` would + /// otherwise keep a tab filed under a group it can no longer draw. + #[test] + fn dropping_a_group_returns_its_tabs_to_auto_grouping() { + use crate::core::group_key::PinnedGroup; + let (store, _dir, ws, first) = store_with_tab(); + let second = store + .tab_create(ws, None, seed(2, "/b"), None, None) + .unwrap(); + let (keep, drop) = (PinnedGroup::label("keep"), PinnedGroup::label("drop")); + let groups = WorkspaceGroups { + pinned: vec![keep.clone(), drop.clone()], + ..Default::default() + }; + store.workspace_set_groups(ws, groups, None).unwrap(); + store + .tab_set_group(ws, first.id, Some(drop.id), None, None) + .unwrap(); + store + .tab_set_group(ws, second.id, Some(keep.id), None, None) + .unwrap(); + + let (_sub, heard) = recorded(&store); + let kept = WorkspaceGroups { + pinned: vec![keep.clone()], + ..Default::default() + }; + store.workspace_set_groups(ws, kept.clone(), None).unwrap(); + + let workspace = store.workspace(ws).unwrap(); + assert_eq!(workspace.groups, kept); + assert_eq!(workspace.tabs[0].group, None, "back to auto grouping"); + assert_eq!(workspace.tabs[1].group, Some(keep.id), "untouched"); + let heard = heard.lock().unwrap(); assert_eq!( - workspace.tabs[1].sidebar_group.as_deref(), - Some("/repo/tty7") + heard.iter().map(|(_, d)| d.clone()).collect::>(), + vec![ + LayoutDelta::TabRegrouped { + tab: first.id, + group: None, + last_auto: None, + }, + LayoutDelta::GroupsChanged { groups: kept }, + ] ); } diff --git a/crates/tty7-core/src/core/session.rs b/crates/tty7-core/src/core/session.rs index db36df48..4eed1d90 100644 --- a/crates/tty7-core/src/core/session.rs +++ b/crates/tty7-core/src/core/session.rs @@ -50,8 +50,13 @@ pub struct SessionTab { #[serde(default)] pub name: Option, pub pane: SessionPane, + /// The pinned sidebar group the tab was in. Carried so a tab reopened + /// with ⌘⇧T goes back where it was, when that group is still there. #[serde(default, skip_serializing_if = "Option::is_none")] - pub sidebar_group: Option, + pub group: Option, + /// The auto group the tab last resolved to, drawn until a probe answers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_auto: Option, #[serde(skip)] pub tree_id: Option, /// Asleep in the tree: comes back as a placeholder holding `pane`, and diff --git a/crates/tty7-core/src/daemon/control.rs b/crates/tty7-core/src/daemon/control.rs index 1543d8ca..3587f55c 100644 --- a/crates/tty7-core/src/daemon/control.rs +++ b/crates/tty7-core/src/daemon/control.rs @@ -53,7 +53,12 @@ use super::protocol::{MAX_FRAME, read_frame, write_frame}; /// half (#857) — would have stayed on this machine and never reached a remote /// one. A new filename is what gets them uploaded, at the price of one Update /// Server per host, which ends the sessions on it. -pub const CONTROL_VERSION: u32 = 10; +/// +/// v11 replaces the sidebar's hand-made groups. A tab now names a pinned group +/// by id (`TabSetGroup` carries a `GroupId`, not a string), the groups +/// themselves live on the workspace and move as `WorkspaceSetGroups` and +/// `LayoutDelta::GroupsChanged`, and a v10 peer can decode none of that. +pub const CONTROL_VERSION: u32 = 11; const DIALECT_MARKER: &str = "speaks control v"; @@ -294,7 +299,15 @@ pub enum ControlRequest { TabSetGroup { workspace: WorkspaceId, tab: TabId, - group: Option, + group: Option, + /// The auto group the tab last resolved to — see `Tab::last_auto`. + #[serde(default)] + last_auto: Option, + }, + /// The workspace's pinned sidebar groups and folds, replaced whole. + WorkspaceSetGroups { + workspace: WorkspaceId, + groups: crate::core::group_key::WorkspaceGroups, }, /// Put a tab to sleep or mark it awake. Going to sleep, the peer also /// stops every pane the tab holds, keeping each one's screen on disk for @@ -413,6 +426,7 @@ impl ControlRequest { | TabRename { .. } | TabMove { .. } | TabSetGroup { .. } + | WorkspaceSetGroups { .. } | TabSetHibernated { .. } | PaneSplit { .. } | PaneClose { .. } diff --git a/crates/tty7-core/src/host/server.rs b/crates/tty7-core/src/host/server.rs index ebcc73ab..6eb9bb9d 100644 --- a/crates/tty7-core/src/host/server.rs +++ b/crates/tty7-core/src/host/server.rs @@ -782,9 +782,15 @@ fn run_request( workspace, tab, group, + last_auto, } => { conn.machine()? - .tab_set_group(workspace, tab, group, conn.machine_origin)?; + .tab_set_group(workspace, tab, group, last_auto, conn.machine_origin)?; + (ReplyOk::Unit, Vec::new()) + } + ControlRequest::WorkspaceSetGroups { workspace, groups } => { + conn.machine()? + .workspace_set_groups(workspace, groups, conn.machine_origin)?; (ReplyOk::Unit, Vec::new()) } ControlRequest::TabSetHibernated { diff --git a/crates/tty7-server/tests/scrollback_restore.rs b/crates/tty7-server/tests/scrollback_restore.rs index 22ae262a..a6d90cfa 100644 --- a/crates/tty7-server/tests/scrollback_restore.rs +++ b/crates/tty7-server/tests/scrollback_restore.rs @@ -95,7 +95,7 @@ impl Instance { {{ "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "name": null, - "sidebar_group": null, + "group": null, "root": {{ "Leaf": {{ "pane": {pane_id} }} }} }} ], diff --git a/docs/cli/reference.mdx b/docs/cli/reference.mdx index ea365f54..9846cbb2 100644 --- a/docs/cli/reference.mdx +++ b/docs/cli/reference.mdx @@ -295,7 +295,7 @@ candidates. | Command | Effect | JSON | |---|---|---| | `ws ls` | Every workspace | `{"workspaces":[...]}` | -| `ws tree [WORKSPACE]` | One workspace as a tree: tabs, split axes and ratios, panes with cwds | The whole workspace object: `{"id","name","last_active","active_tab","tabs":[{"id","name","sidebar_group","root",…}]}` | +| `ws tree [WORKSPACE]` | One workspace as a tree: tabs, split axes and ratios, panes with cwds | The whole workspace object: `{"id","name","last_active","active_tab","groups":{"pinned":[…]},"tabs":[{"id","name","group","last_auto","root",…}]}` | | `ws new [NAME]` | An empty workspace (no tab, no pane) | `{"id","name"}` | | `ws rename WORKSPACE NAME` | Name or rename | `{"id","name"}` | | `ws rm WORKSPACE` | Delete the workspace and hang up its panes | `{"removed"}` | @@ -342,9 +342,11 @@ tree drops a pane's record when the tab holding it closes, and the registry is what is left. That only matters if the shell later dies, since a restore would then have nothing to restore from. -`GROUP` is the heading the GUI's sidebar files the tab under, shown by its last -segment. Read-only from here: with the default repo grouping the GUI recomputes -it from the tab's working directory. +`GROUP` is the pinned sidebar group the tab is kept in — its name, or the last +segment of its folder — and `-` for a tab the GUI groups automatically. The JSON +carries the group's `id`, `name` and `folder` (or `null`). Auto groups are not +listed: the GUI works them out from each tab's working directory and stores +nothing about them. `label` falls back through the best evidence available — the name if someone set one, else the agent running there, else the last segment of the cwd, else the diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx index eb06a642..72e196d6 100644 --- a/docs/customization/settings.mdx +++ b/docs/customization/settings.mdx @@ -31,8 +31,8 @@ section something is in. Keyboard shortcuts, Option as Meta, mouse behavior, selection, and clipboard. - Tab position and placement, sidebar grouping, diff previews, and what SSH - tabs are called. + Tab position and placement, sidebar auto grouping, diff previews, and what + SSH tabs are called. Hosts, connection defaults, security, authentication, proxies, and forwarding. diff --git a/docs/reference/configuration.mdx b/docs/reference/configuration.mdx index 12e49c9b..f7af4865 100644 --- a/docs/reference/configuration.mdx +++ b/docs/reference/configuration.mdx @@ -93,7 +93,7 @@ their id from the file name. [More about themes →](/customization/themes) | `tab_bar_position` | enum | `"left"` | `left` (sidebar) or `top` (strip). | | `new_tab_position` | enum | `"after-current"` | Or `end`. | | `ssh_tab_title` | enum | `"dynamic"` | What an SSH tab is called: `dynamic` (the title the remote side sets), `profile-name` (the saved host's name, a `~/.ssh/config` alias, or the address typed for a quick connect), or `hostname` (the address dialled). A tab you renamed keeps its name. | -| `sidebar_grouping` | enum | `"repo"` | Or `repo-or-directory` to group non-repo tabs by their folder, or `none` for a flat list. | +| `sidebar_auto_grouping` | bool | `true` | Groups unpinned tabs by repository (SSH tabs by host). Off: a flat list below the pinned groups, which show either way. Pinned groups and folds are stored with the workspace, not here. | | `sidebar_diff_preview` | bool | `true` | Clicking a row's `+N −M` opens the diff overlay. | | `sidebar_width` | number | `220` | Pixels (100–2000). | | `sidebar_collapsed` | bool | `false` | | diff --git a/docs/window/sidebar.mdx b/docs/window/sidebar.mdx index 1eacf956..a5c32cd5 100644 --- a/docs/window/sidebar.mdx +++ b/docs/window/sidebar.mdx @@ -1,6 +1,6 @@ --- title: "The sidebar" -description: "Tabs grouped by repository, with branch, diff counts, and agent status on every row." +description: "Tabs grouped by repository automatically, groups you pin kept above them, and branch, diff counts, and agent status on every row." --- The left sidebar is tty7's tab bar, and it is the default because a vertical row @@ -14,18 +14,54 @@ is doing. The tty7 tab sidebar -## Grouped by repository +## Groups -Rows sit under a header per git repository, with everything else collected in a -trailing **Scratch** section. The grouping follows the tab's working directory, -not its history — switching branches or `cd`-ing around inside a repository -never moves a row out from under its header. +The sidebar groups tabs by repository automatically. Pin what you want to keep. -**Settings → Window & Tabs → Sidebar grouping** switches between *By repo* (the -default), *By repo or folder*, and *Flat*. The default collects every non-repo -tab in Scratch; *By repo or folder* groups those tabs under their working -directory instead — for agents running in plain folders — leaving Scratch to -tabs whose shell has not reported a directory yet. +**Auto groups** sit below a divider. Every tab you have not pinned is filed +under the git repository its working directory is in — a linked worktree under +the repository it belongs to, a submodule under itself — and an SSH tab under +the host it is connected to, so `/home/ubuntu` on two machines is two groups. +Everything else collects in a trailing **Ungrouped** section. An auto group +follows the tab's working directory, not its history: `cd` into another +repository and the row moves; switching branches never does. When its last tab +leaves, the group is gone. + +**Pinned groups** sit above the divider, in the order you put them, and stay +until you delete them — an empty one keeps its place with a **+ New Tab** row. +A tab in a pinned group never leaves it on its own. There are two kinds: + +- A **folder group** keeps a directory. A tab whose working directory *enters* + the folder joins it — a tab opened there, a `cd` into it, or a worktree of the + repository the folder is. When folders nest, the deepest one wins, so pinning + a monorepo and one package in it files the package's tabs under the package. + A tab you drag out while it is still inside stays out until it leaves the + folder and comes back. Hover the header for the folder's path; click its pin + to unpin it, and its tabs go back to auto groups. +- A **label group** is just a name, for tabs that belong together for a reason + no directory shows. Tabs go in and out of it by hand. + +Ways to pin: + +| To get | Do this | +|---|---| +| A folder group from an auto group | Click the pin on its header, or drag the header above the divider | +| A folder group from a folder | Drop it from Finder onto the sidebar, choose **Pin as Group** on it in the Files panel, or run **Open Folder as Group…** from the command palette | +| A label group | Right-click a tab → **Move to Group → New Group…**, or run **New Group** from the command palette | + +Right-click a pinned group's header to rename it, point it at a folder (**Set +Folder…**, or **Use Current Tab's Folder**), clear its folder, open a tab in it, +or delete it. Deleting a group closes nothing: its tabs go back to auto +grouping. An auto group has no rename — pin it first. The **+** on a header +opens a tab in that group's folder (a label group's opens where ⌘ T +would), and ⌘ T itself joins the group of the tab you are in. + +Groups, their order, and which ones are folded are kept with the workspace, so +every window onto it — and the next launch — shows the same thing. + +**Settings → Window & Tabs → Auto grouping** turns auto groups off. The tabs you +have not pinned then sit in one flat list below your pinned groups, which show +either way. ## What a row tells you @@ -54,9 +90,11 @@ they simply stop opening the overlay. ## Rearranging -Drag a row to reorder it within its group, or drag a whole group header to move -the group. A row cannot be dragged into a different group: a tab's group comes -from its working directory, so `cd` is what moves it. +Drag a row to reorder it within its group, or drag a group header to move the +group — pinned groups among pinned groups, auto groups among auto groups. Drag +a row onto a pinned group to put it there, or anywhere below the divider to +hand it back to auto grouping. A row cannot be dragged into an auto group: its +membership comes from the working directory, so `cd` is what moves it. Drag a row out over the panes instead and it stops being a session of its own: it lands as a pane of the tab on screen, wherever the highlight says. Dragging a diff --git a/src/terminal/view.rs b/src/terminal/view.rs index fa3e03c7..6ccac9f9 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -269,23 +269,6 @@ fn cwd_is_on_host(pane_runs_remotely: bool, host_is_local: bool) -> bool { } } -/// The cwd a native SSH pane's remote shell reported, for the few readers that -/// only need a name for it and not a host to act on it. -/// -/// Such a pane belongs to this machine's daemon, so [`cwd_is_on_host`] rightly -/// turns its paths away from every `Host` call — there is no host to hand them -/// to. But the shell on the far end states them itself (OSC 7), unlike a shell -/// that ssh'd onward from a local prompt, whose directory is only ever a guess. -/// Only an absolute POSIX path counts: that is what a remote sshd's shell -/// reports, and anything else is not a directory worth naming. -fn native_ssh_cwd( - remote: Option<&RemoteContext>, - cwd: Option, -) -> Option { - remote.filter(|r| r.kind == crate::daemon::protocol::RemoteKind::NativeSsh)?; - cwd.filter(|c| c.to_string_lossy().starts_with('/')) -} - /// Which path dialect a pane's output is written in. /// /// A pane running on this machine spells paths the way this OS does, and that @@ -2035,12 +2018,6 @@ impl TerminalView { self.git_status_cwd.as_deref() } - /// See [`native_ssh_cwd`]. `None` for every pane that is not a native SSH - /// one — those either have a `git_status_cwd` or have no cwd to name. - pub fn native_ssh_cwd(&self) -> Option { - native_ssh_cwd(self.remote_context().as_ref(), self.cwd()) - } - /// Plant the cwd the git-status poll would have found. For tests that /// need a pane to look like it is sitting somewhere known — a real poll /// needs a live shell reporting a directory, which a quiet test pane has @@ -10089,37 +10066,6 @@ mod tests { assert!(!cwd_is_on_host(false, false)); } - #[test] - fn only_a_native_ssh_pane_names_its_remote_cwd() { - use super::{RemoteContext, native_ssh_cwd}; - use std::path::PathBuf; - let native = RemoteContext { - kind: RemoteKind::NativeSsh, - argv: Vec::new(), - target: "ubuntu@box".into(), - }; - let home = || Some(PathBuf::from("/home/ubuntu")); - assert_eq!(native_ssh_cwd(Some(&native), home()), home()); - - // `ssh` typed at a local prompt: the directory is a guess, not a - // report from the far end. - let typed = RemoteContext { - kind: RemoteKind::Ssh, - ..native.clone() - }; - assert_eq!(native_ssh_cwd(Some(&typed), home()), None); - assert_eq!(native_ssh_cwd(Some(&wsl_context("Ubuntu")), home()), None); - // A local pane has its own path through `git_status_cwd`. - assert_eq!(native_ssh_cwd(None, home()), None); - - assert_eq!(native_ssh_cwd(Some(&native), None), None); - assert_eq!( - native_ssh_cwd(Some(&native), Some(PathBuf::from("~"))), - None, - "only an absolute path names a directory" - ); - } - /// Which machine's spelling a pane's paths are read in. Ungated on /// purpose: the bug this settles was a Windows-only one that hid behind a /// `#[cfg(unix)]` on the test that covered it. diff --git a/src/ui/app.rs b/src/ui/app.rs index b09a0f9a..5d6907a1 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -405,7 +405,19 @@ pub struct Tab { pub(crate) zoomed: Option>, pub(crate) diff_overlay: Option, pub(crate) code: Option>, - pub(crate) sidebar_group: std::cell::RefCell>, + /// The pinned sidebar group this tab was put in, by id, or `None` to + /// leave it to auto grouping. A `Cell` because the sidebar files a tab + /// that walks into a pinned folder while it is working out where to draw + /// it, which it does from `&self`. + pub(crate) group: std::cell::Cell>, + /// The auto group this tab last resolved to. Stored nowhere — an auto + /// group is derived — but remembered here so a tab whose repo probe is + /// still in flight stays where it was instead of bouncing through + /// Ungrouped for the frames the probe takes. + pub(crate) auto_group: std::cell::RefCell>, + /// Which pinned folder this tab was last seen inside, so it joins one only + /// on the way *in* — see [`crate::core::group_key::EntryWatch`]. + pub(crate) folder_watch: 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` @@ -478,7 +490,9 @@ impl Tab { code: None, overlay_top: OverlayTop::default(), document_layout: None, - sidebar_group: std::cell::RefCell::new(None), + group: std::cell::Cell::new(None), + auto_group: std::cell::RefCell::new(None), + folder_watch: std::cell::Cell::new(crate::core::group_key::EntryWatch::fresh()), tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()), last_used: std::cell::Cell::new(0), focus_origin: Default::default(), @@ -496,11 +510,13 @@ impl Tab { code: None, overlay_top: OverlayTop::default(), document_layout: None, - sidebar_group: std::cell::RefCell::new( - tree.sidebar_group - .as_deref() - .and_then(crate::core::group_key::GroupKey::decode), - ), + group: std::cell::Cell::new(tree.group), + auto_group: std::cell::RefCell::new(tree.last_auto.clone()), + // Only ever built for a tab that was just created elsewhere — by + // `tty7 tab new`, or another window — so it is as new as one + // opened here: opened inside a pinned folder, it joins it. Every + // window that hears of it reaches the same answer. + folder_watch: std::cell::Cell::new(crate::core::group_key::EntryWatch::fresh()), tree_id: std::cell::Cell::new(tree.id), last_used: std::cell::Cell::new(0), focus_origin: Default::default(), @@ -798,13 +814,9 @@ pub(crate) struct WorkspaceRename { } pub(crate) struct GroupRename { - /// The group being renamed, by the key it had when the box opened. - /// - /// A custom group *is* its name — there is no group record anywhere for - /// an id to point at, only the tabs that claim it. So renaming one means - /// rewriting every tab that says the old name, and this is what says - /// which those are. - pub(crate) key: crate::core::group_key::GroupKey, + /// The pinned group being renamed. By id, so a rename that lands after + /// another window reordered or renamed the groups still names this one. + pub(crate) group: crate::core::group_key::GroupId, pub(crate) input: Entity, pub(crate) _subs: Vec, } @@ -962,12 +974,21 @@ pub struct Tty7App { /// the sidebar — and takes no part in the reading. pub(crate) strip_slots: Rc>>>, pub(crate) sidebar_slots: Rc>>>, - /// Where each custom group's block was drawn last frame, so a tab held - /// over one can be told which group it is over. Only custom groups are - /// here: a repo group's membership is decided by cwd, so dropping a tab + /// Where each pinned group's block was drawn last frame, so a tab held + /// over one can be told which group it is over. Only pinned groups are + /// here: an auto group's membership is decided by cwd, so dropping a tab /// into one has no meaning to record. pub(crate) sidebar_group_slots: - Rc)>>>, + Rc)>>>, + /// Where the divider between the pinned groups and the rest was drawn + /// last frame. Everything below it is auto grouping's: a tab dropped + /// there leaves its pinned group, and an auto group's header lifted + /// above it is pinned. + pub(crate) sidebar_divider: Rc>>>, + /// This workspace's pinned groups and folds, as the machine tree holds + /// them. Adopted from every pull and every `GroupsChanged`, and pushed + /// back as one `WorkspaceSetGroups` whenever this window edits it. + pub(crate) sidebar_groups: crate::core::group_key::WorkspaceGroups, /// Where the active tab's panes were last drawn, which is the frame of /// reference a drag's landing is worked out in. pub(crate) pane_area: Rc>>>, @@ -1579,6 +1600,8 @@ impl Tty7App { strip_slots: Rc::new(RefCell::new(Vec::new())), sidebar_slots: Rc::new(RefCell::new(Vec::new())), sidebar_group_slots: Rc::new(RefCell::new(Vec::new())), + sidebar_divider: Rc::new(Cell::new(None)), + sidebar_groups: Default::default(), pane_area: Rc::new(Cell::new(None)), sidebar_search, _sidebar_search_sub: sidebar_search_sub, @@ -1938,7 +1961,11 @@ impl Tty7App { code: None, overlay_top: OverlayTop::default(), document_layout: None, - sidebar_group: std::cell::RefCell::new(st.sidebar_group), + group: std::cell::Cell::new(st.group), + auto_group: std::cell::RefCell::new(st.last_auto), + // Reopened here, so it is this window's new tab: walking into + // a pinned folder files it there like any other. + folder_watch: std::cell::Cell::new(crate::core::group_key::EntryWatch::fresh()), tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()), last_used: std::cell::Cell::new(0), focus_origin: Default::default(), @@ -3584,12 +3611,8 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.tab_bar_position = pos); } - pub(crate) fn set_sidebar_grouping( - &mut self, - grouping: crate::core::config::SidebarGrouping, - cx: &mut Context, - ) { - self.update_config(cx, |cfg| cfg.sidebar_grouping = grouping); + pub(crate) fn set_sidebar_auto_grouping(&mut self, on: bool, cx: &mut Context) { + self.update_config(cx, |cfg| cfg.sidebar_auto_grouping = on); } pub(crate) fn set_sidebar_diff_preview(&mut self, on: bool, cx: &mut Context) { @@ -4077,9 +4100,7 @@ impl Tty7App { self.maximized = None; let insert_at = self.new_tab_insert_at(cx); let new_tab = Tab::new(Pane::leaf(tab.clone())); - if let Some(group) = group { - *new_tab.sidebar_group.borrow_mut() = group; - } + group.seat(&new_tab); self.tabs.insert(insert_at, new_tab); self.active = insert_at; self.focus_active(window, cx); @@ -4749,9 +4770,7 @@ impl Tty7App { .and_then(|view| view.read(cx).spawnable_cwd()); let group = self.spawn_group(cwd.as_deref(), cx); let fresh = Tab::new(crate::ui::pane::Pane::leaf(slot)); - if let Some(group) = group { - *fresh.sidebar_group.borrow_mut() = group; - } + group.seat(&fresh); let at = at.min(self.tabs.len()); self.tabs.insert(at, fresh); self.maximized = None; @@ -5415,9 +5434,7 @@ impl Tty7App { self.maximized = None; let insert_at = self.new_tab_insert_at(cx); let tab = Tab::new(Pane::leaf(new)); - if let Some(group) = group { - *tab.sidebar_group.borrow_mut() = group; - } + group.seat(&tab); self.tabs.insert(insert_at, tab); self.active = insert_at; self.focus_active(window, cx); @@ -5765,6 +5782,32 @@ impl Tty7App { // saved host has nothing to save, and a pane that is not an SSH one has // no connection at all — either would be a row that quietly did nothing // (#549). + // Groups are something the sidebar draws, so they are offered only + // while the tabs are in it. Opening a folder as a group asks the + // system picker, which browses this computer: a path picked there + // names nothing on a remote workspace's machine, where "Pin as + // Group" in the file tree is the way in instead. + if cx.global::().tab_bar_position == TabBarPosition::Left { + commands.push( + Command::localized(L10nKey::CmdNewGroup, CommandKind::NewGroup) + .with_subtitle(t(L10nKey::CmdNewGroupSubtitle)) + .in_group(CommandGroup::TabsPanes), + ); + if !WorkspaceStore::all(cx) + .get(self.workspace) + .is_some_and(|w| w.is_remote()) + { + commands.push( + Command::localized( + L10nKey::CmdOpenFolderAsGroup, + CommandKind::OpenFolderAsGroup, + ) + .with_subtitle(t(L10nKey::CmdOpenFolderAsGroupSubtitle)) + .in_group(CommandGroup::TabsPanes), + ); + } + } + if self.unsaved_ssh_session(window, cx).is_some() { commands.push( Command::localized( @@ -5984,6 +6027,8 @@ impl Tty7App { ReopenClosedTab => self.reopen_closed_tab(window, cx), RenameTab => self.start_rename(self.active, window, cx), NewWorktreeTab => self.new_worktree_tab(self.active, window, cx), + NewGroup => self.new_empty_group(window, cx), + OpenFolderAsGroup => self.open_folder_as_group(cx), CloseOtherTabs => self.close_other_tabs(self.active, window, cx), CloseTabsToTheRight => self.close_tabs_right_of(self.active, window, cx), CopyWorkingDirectory => self.copy_active_cwd(window, cx), @@ -6182,7 +6227,7 @@ impl Tty7App { self.set_tab_bar_position(defaults.tab_bar_position, cx) } L10nKey::SettingsSidebarGrouping => { - self.set_sidebar_grouping(defaults.sidebar_grouping, cx) + self.set_sidebar_auto_grouping(defaults.sidebar_auto_grouping, cx) } L10nKey::SettingsDiffPreviewFromCounts => { self.set_sidebar_diff_preview(defaults.sidebar_diff_preview, cx) @@ -8639,6 +8684,9 @@ impl Render for Tty7App { // terminal grid, sized in absolute px from `font_size`, does not move. window.set_rem_size(px(cx.global::().ui_font_size)); self.claim_pending_tab(window, cx); + // Before anything asks where a tab is drawn: a tab that walked into a + // pinned folder since the last frame is filed there on this one. + self.settle_sidebar_groups(cx); self.touch_active_tab(); self.declare_displayed_panes(cx); self.scm_sync_watchers(window, cx); @@ -8655,14 +8703,23 @@ impl Render for Tty7App { let landed = crate::ui::reorder::take_landed(&self.reorder); if let Some((tab, zone)) = self.tab_merge.take() { self.merge_tab(tab, zone, window, cx); - } else if let Some((tab, key)) = landed.regroup { + } else if let Some((tab, target)) = landed.regroup { // A drop into another group outranks the reordering the drag // did on its way out of the one it came from. The pointer // left that group; the shuffle it caused before leaving is // not what was being asked for. - self.regroup_tab(tab, key, cx); + self.regroup_tab(tab, target, cx); + } else if let Some(key) = landed.pin { + // The same for a header let go above the divider: it was + // carried there to be pinned, not to be reordered on the way. + self.pin_auto_group(key, cx); } else if let Some(order) = landed.order { - self.apply_tab_order(&order, cx); + match landed.surface { + Some(crate::ui::reorder::Surface::PinnedGroups) => { + self.apply_pinned_order(&order, cx) + } + _ => self.apply_tab_order(&order, cx), + } } // Also what ends the pane drag, so it is taken whichever of the two // readings the last frame left behind. @@ -9437,7 +9494,8 @@ fn tab_to_session(tab: &Tab, cx: &App) -> SessionTab { Some(layout) => layout.clone(), None => pane_to_session(&tab.pane, cx), }, - sidebar_group: tab.sidebar_group.borrow().clone(), + group: tab.group.get(), + last_auto: tab.auto_group.borrow().clone(), tree_id: None, hibernated: false, asleep_view: None, @@ -9652,7 +9710,11 @@ fn tabs_from_session( code: None, overlay_top: OverlayTop::default(), document_layout: None, - sidebar_group: std::cell::RefCell::new(st.sidebar_group.clone()), + group: std::cell::Cell::new(st.group), + // The hint the tree kept: the tab is drawn in its auto group from + // the first frame, not parked in Ungrouped until its probe lands. + auto_group: std::cell::RefCell::new(st.last_auto.clone()), + folder_watch: std::cell::Cell::new(crate::core::group_key::EntryWatch::baseline()), tree_id: std::cell::Cell::new( st.tree_id .unwrap_or_else(tty7_core::core::machine::TabId::new), @@ -9671,7 +9733,8 @@ fn tabs_from_session( fn asleep_tab(st: &SessionTab, home: Option) -> Tab { let mut tab = Tab::new(Pane::Empty); tab.name = st.name.clone(); - *tab.sidebar_group.borrow_mut() = st.sidebar_group.clone(); + tab.group.set(st.group); + *tab.auto_group.borrow_mut() = st.last_auto.clone(); if let Some(id) = st.tree_id { tab.tree_id.set(id); } @@ -11548,7 +11611,8 @@ mod ssh_rebuild_gpui_tests { let tab = TreeTab { id: app.tabs[0].tree_id.get(), name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Leaf { pane: 1 }, hibernated: false, }; diff --git a/src/ui/assets.rs b/src/ui/assets.rs index 1731232b..73ffca40 100644 --- a/src/ui/assets.rs +++ b/src/ui/assets.rs @@ -34,6 +34,7 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> { "icons/panel-left.svg" => include_bytes!("../../assets/icons/panel-left.svg"), "icons/panel-right.svg" => include_bytes!("../../assets/icons/panel-right.svg"), "icons/plus.svg" => include_bytes!("../../assets/icons/plus.svg"), + "icons/pin.svg" => include_bytes!("../../assets/icons/pin.svg"), "icons/ellipsis.svg" => include_bytes!("../../assets/icons/ellipsis.svg"), "icons/folder-closed.svg" => include_bytes!("../../assets/icons/folder-closed.svg"), "icons/folder-open.svg" => include_bytes!("../../assets/icons/folder-open.svg"), diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index e290a5ff..dfaf5553 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -1962,7 +1962,6 @@ impl Tty7App { let show_hidden = self.file_tree.show_hidden; let paths_are_local = self.spawn_host(cx).is_local(); move |menu, _window, cx| { - let danger = cx.theme().danger; Self::tree_row_context_menu( menu, &path, @@ -1970,8 +1969,8 @@ impl Tty7App { is_root, show_hidden, paths_are_local, - danger, &app, + cx, ) } }); @@ -2012,9 +2011,16 @@ impl Tty7App { // here — silently opening nothing, or the wrong thing if a local path // happens to collide. paths_are_local: bool, - danger: gpui::Hsla, app: &gpui::WeakEntity, + cx: &App, ) -> PopupMenu { + let danger = cx.theme().danger; + // Whether "Pin as Group" has a sidebar to put the group in: with the + // tabs along the top there is nowhere to show one. Offered on a remote + // workspace too, unlike the file manager above — the tree and a pinned + // folder are both on the workspace's own host. + let groups_shown = cx.global::().tab_bar_position + == crate::core::config::TabBarPosition::Left; let mut menu = menu.min_w(px(200.)); let p = path.to_path_buf(); @@ -2039,6 +2045,19 @@ impl Tty7App { } }), ); + if groups_shown { + menu = menu.item( + PopupMenuItem::new(t(L10nKey::FileTreeContextPinAsGroup)).on_click({ + let app = app.clone(); + let p = p.clone(); + move |_, _window, cx| { + let _ = app.update(cx, |this, cx| { + this.pin_folder(p.clone(), cx); + }); + } + }), + ); + } } menu = menu .item( diff --git a/src/ui/home.rs b/src/ui/home.rs index 6f2903de..e3fcac18 100644 --- a/src/ui/home.rs +++ b/src/ui/home.rs @@ -494,7 +494,8 @@ mod tests { let tab = SessionTab { name: Some("build".into()), tree_id: None, - sidebar_group: None, + group: None, + last_auto: None, hibernated: false, asleep_view: None, pane: leaf(Some("/work/getty")), @@ -507,7 +508,8 @@ mod tests { let tab = SessionTab { name: None, tree_id: None, - sidebar_group: None, + group: None, + last_auto: None, hibernated: false, asleep_view: None, pane: leaf(Some("/work/getty")), @@ -517,7 +519,8 @@ mod tests { let tab = SessionTab { name: Some(" ".into()), tree_id: None, - sidebar_group: None, + group: None, + last_auto: None, hibernated: false, asleep_view: None, pane: leaf(Some("/work/getty")), @@ -530,7 +533,8 @@ mod tests { let tab = SessionTab { name: None, tree_id: None, - sidebar_group: None, + group: None, + last_auto: None, hibernated: false, asleep_view: None, pane: SessionPane::Split { @@ -548,7 +552,8 @@ mod tests { let unnamed = SessionTab { name: None, tree_id: None, - sidebar_group: None, + group: None, + last_auto: None, hibernated: false, asleep_view: None, pane: leaf(None), @@ -557,7 +562,8 @@ mod tests { let root = SessionTab { name: None, tree_id: None, - sidebar_group: None, + group: None, + last_auto: None, hibernated: false, asleep_view: None, pane: leaf(Some("/")), @@ -570,7 +576,8 @@ mod tests { let tab = SessionTab { name: Some("a".repeat(40)), tree_id: None, - sidebar_group: None, + group: None, + last_auto: None, hibernated: false, asleep_view: None, pane: leaf(None), diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index e5efd553..cd1991b9 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -612,9 +612,9 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsTabBarPositionDesc => { "Show tabs as a horizontal strip on top or a vertical sidebar on the left." } - L10nKey::SettingsSidebarGrouping => "Sidebar grouping", + L10nKey::SettingsSidebarGrouping => "Auto grouping", L10nKey::SettingsSidebarGroupingDesc => { - "Group sidebar tabs by git repository. Tabs outside a repo collect under Scratch, or under their working directory with \"By repo or folder\". Left sidebar only." + "Group unpinned sidebar tabs by git repository, and SSH tabs by host. Pinned groups always show. Left sidebar only." } L10nKey::SettingsDiffPreviewFromCounts => "Open diff preview from sidebar counts", L10nKey::SettingsDiffPreviewFromCountsDesc => { @@ -649,9 +649,6 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsAtEnd => "At end", L10nKey::SettingsTop => "Top", L10nKey::SettingsLeft => "Left", - L10nKey::SettingsByRepo => "By repo", - L10nKey::SettingsByRepoOrFolder => "By repo or folder", - L10nKey::SettingsFlat => "Flat", L10nKey::SettingsPreset => "Preset", L10nKey::SettingsPresetDesc => { "tmux remaps pane/tab actions onto prefix sequences (e.g. Ctrl-B then C)." @@ -948,7 +945,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { "tray menu bar status item agent attention system icon" } L10nKey::SettingsSearchSidebarGroupingKeywords => { - "tabs group repo repository git scratch header sidebar flat folder directory cwd" + "tabs group grouping auto repo repository git ssh host pinned pin ungrouped header sidebar flat folder" } L10nKey::SettingsSearchSshTabTitleKeywords => { "ssh tab title name host hostname profile alias pin fixed osc remote" @@ -1071,6 +1068,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { } L10nKey::FileTreeContextOpen => "Open", L10nKey::FileTreeContextCdHere => "cd Here", + L10nKey::FileTreeContextPinAsGroup => "Pin as Group", L10nKey::FileTreeContextInsertPath => "Insert Path in Terminal", L10nKey::FileTreeContextAttachAgent => "Attach to Agent", L10nKey::FileTreeContextNewFile => "New File", @@ -1502,6 +1500,10 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::CmdNewWindow => "New Window", L10nKey::CmdNewWorktreeTab => "New Worktree Tab…", L10nKey::CmdNewWorktreeTabSubtitle => "isolated checkout on a fresh branch", + L10nKey::CmdNewGroup => "New Group", + L10nKey::CmdNewGroupSubtitle => "an empty pinned group in the sidebar", + L10nKey::CmdOpenFolderAsGroup => "Open Folder as Group…", + L10nKey::CmdOpenFolderAsGroupSubtitle => "pin a folder; tabs opened in it join", L10nKey::CmdRenameTab => "Rename Tab…", L10nKey::CmdSplitRight => "Split Right", L10nKey::CmdSplitDown => "Split Down", @@ -1924,12 +1926,19 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::TabMenuSplitHint => "Hold {key} to split", L10nKey::TabUnnamedShell => "Shell {n}", L10nKey::ShellDefault => "default", - L10nKey::SidebarScratchGroup => "Scratch", + L10nKey::SidebarUngroupedGroup => "Ungrouped", L10nKey::SidebarMoveToGroup => "Move to Group", L10nKey::SidebarNewGroup => "New Group…", - L10nKey::SidebarAutoGroup => "Group Automatically", L10nKey::SidebarNewGroupName => "New Group", L10nKey::SidebarRenameGroup => "Rename Group", + L10nKey::SidebarPinGroup => "Pin Group", + L10nKey::SidebarUnpinGroup => "Unpin", + L10nKey::SidebarGroupNewTab => "New Tab", + L10nKey::SidebarSetGroupFolder => "Set Folder…", + L10nKey::SidebarUseCurrentTabFolder => "Use Current Tab's Folder", + L10nKey::SidebarClearGroupFolder => "Clear Folder", + L10nKey::SidebarDeleteGroup => "Delete Group", + L10nKey::SidebarDropToPin => "Drop here to pin", 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 74e7a74a..a2502336 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -620,9 +620,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsTabBarPositionDesc => { "タブを上部の横一列または左側の縦サイドバーとして表示" } - L10nKey::SettingsSidebarGrouping => "サイドバーのグループ化", + L10nKey::SettingsSidebarGrouping => "自動グループ化", L10nKey::SettingsSidebarGroupingDesc => { - "サイドバータブを git リポジトリごとにまとめます。リポジトリ外のタブはスクラッチに、「リポジトリ/フォルダ別」なら作業ディレクトリごとに。左サイドバーのみ" + "固定していないサイドバータブを git リポジトリごとに、SSH タブをホストごとにまとめます。固定したグループは常に表示されます。左サイドバーのみ" } L10nKey::SettingsDiffPreviewFromCounts => "サイドバーのカウントから Diff プレビューを開く", L10nKey::SettingsDiffPreviewFromCountsDesc => { @@ -657,9 +657,6 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAtEnd => "末尾", L10nKey::SettingsTop => "上部", L10nKey::SettingsLeft => "左側", - L10nKey::SettingsByRepo => "リポジトリ別", - L10nKey::SettingsByRepoOrFolder => "リポジトリ/フォルダ別", - L10nKey::SettingsFlat => "フラット表示", L10nKey::SettingsPreset => "プリセット", L10nKey::SettingsPresetDesc => { "tmux では、ペイン/タブの操作をプレフィックスキーの後に行います(例: Ctrl-B の後に C)" @@ -1010,7 +1007,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { "トレイ メニューバー ステータス アイコン エージェント 通知 システム tray icon menu bar status system attention" } L10nKey::SettingsSearchSidebarGroupingKeywords => { - "タブ グループ リポジトリ git スクラッチ ヘッダー サイドバー フラット フォルダ ディレクトリ sidebar grouping tabs repo repository git scratch header flat folder directory" + "タブ グループ 自動 リポジトリ git ssh ホスト 固定 未分類 サイドバー sidebar auto grouping tabs repo repository pinned pin host ungrouped" } L10nKey::SettingsSearchSshTabTitleKeywords => { "SSH タブ タイトル 名前 ホスト ホスト名 プロファイル エイリアス 固定 ssh tab title name host hostname profile alias pin" @@ -1137,6 +1134,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { } L10nKey::FileTreeContextOpen => "開く", L10nKey::FileTreeContextCdHere => "ここで cd", + L10nKey::FileTreeContextPinAsGroup => "グループとして固定", L10nKey::FileTreeContextInsertPath => "ターミナルにパスを挿入", L10nKey::FileTreeContextAttachAgent => "エージェントをアタッチ", L10nKey::FileTreeContextNewFile => "新しいファイル", @@ -1566,6 +1564,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::CmdNewWindow => "新しいウィンドウ", L10nKey::CmdNewWorktreeTab => "新しいワークツリータブ…", L10nKey::CmdNewWorktreeTabSubtitle => "新しいブランチでの独立したチェックアウト", + L10nKey::CmdNewGroup => "新規グループ", + L10nKey::CmdNewGroupSubtitle => "サイドバーに空の固定グループを作成", + L10nKey::CmdOpenFolderAsGroup => "フォルダをグループとして開く…", + L10nKey::CmdOpenFolderAsGroupSubtitle => "フォルダを固定し、そこで開いたタブをまとめる", L10nKey::CmdRenameTab => "タブの名前を変更…", L10nKey::CmdSplitRight => "右に分割", L10nKey::CmdSplitDown => "下に分割", @@ -1996,12 +1998,19 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::TabMenuSplitHint => "{key} を押しながら選ぶと分割", L10nKey::TabUnnamedShell => "シェル {n}", L10nKey::ShellDefault => "デフォルト", - L10nKey::SidebarScratchGroup => "スクラッチ", + L10nKey::SidebarUngroupedGroup => "未分類", L10nKey::SidebarMoveToGroup => "グループへ移動", L10nKey::SidebarNewGroup => "新規グループ…", - L10nKey::SidebarAutoGroup => "自動グループ化に戻す", L10nKey::SidebarNewGroupName => "新規グループ", L10nKey::SidebarRenameGroup => "グループ名を変更", + L10nKey::SidebarPinGroup => "グループを固定", + L10nKey::SidebarUnpinGroup => "固定を解除", + L10nKey::SidebarGroupNewTab => "新規タブ", + L10nKey::SidebarSetGroupFolder => "フォルダを設定…", + L10nKey::SidebarUseCurrentTabFolder => "現在のタブのフォルダを使用", + L10nKey::SidebarClearGroupFolder => "フォルダを解除", + L10nKey::SidebarDeleteGroup => "グループを削除", + L10nKey::SidebarDropToPin => "ここにドロップして固定", L10nKey::TabContextCloseTab => "タブを閉じる", L10nKey::TabContextCloseTabsBelow => "下のタブを閉じる", L10nKey::AppAgentHooksOpFailed => "失敗: {error}", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 43f94f69..4e8eaf92 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -521,9 +521,6 @@ l10n_keys! { SettingsAtEnd, SettingsTop, SettingsLeft, - SettingsByRepo, - SettingsByRepoOrFolder, - SettingsFlat, SettingsPreset, SettingsPresetDesc, SettingsPrefix, @@ -778,6 +775,7 @@ l10n_keys! { FileTreeDownloadTooLarge, FileTreeContextOpen, FileTreeContextCdHere, + FileTreeContextPinAsGroup, FileTreeContextInsertPath, FileTreeContextAttachAgent, FileTreeContextNewFile, @@ -1079,12 +1077,19 @@ l10n_keys! { TabMenuSplitHint, TabUnnamedShell, ShellDefault, - SidebarScratchGroup, + SidebarUngroupedGroup, SidebarMoveToGroup, SidebarNewGroup, - SidebarAutoGroup, SidebarNewGroupName, SidebarRenameGroup, + SidebarPinGroup, + SidebarUnpinGroup, + SidebarGroupNewTab, + SidebarSetGroupFolder, + SidebarUseCurrentTabFolder, + SidebarClearGroupFolder, + SidebarDeleteGroup, + SidebarDropToPin, TabContextCloseTab, TabContextCloseTabsBelow, TabContextMarkUnread, @@ -1211,6 +1216,10 @@ l10n_keys! { CmdNewWindow, CmdNewWorktreeTab, CmdNewWorktreeTabSubtitle, + CmdNewGroup, + CmdNewGroupSubtitle, + CmdOpenFolderAsGroup, + CmdOpenFolderAsGroupSubtitle, CmdRenameTab, CmdSplitRight, CmdSplitDown, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 7e5dc6c3..092a77dd 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -541,9 +541,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsNewTabPositionDesc => "新打开的标签页插入的位置。", L10nKey::SettingsTabBarPosition => "标签栏位置", L10nKey::SettingsTabBarPositionDesc => "将标签页显示为顶部横向条或左侧垂直侧栏。", - L10nKey::SettingsSidebarGrouping => "侧栏分组", + L10nKey::SettingsSidebarGrouping => "自动分组", L10nKey::SettingsSidebarGroupingDesc => { - "按 git 仓库给侧栏标签页分组。仓库外的标签页归到“草稿”,选“按仓库或文件夹”时则按工作目录分。仅左侧栏。" + "把未固定的侧栏标签页按 git 仓库分组,SSH 标签页按主机分组。固定的分组始终显示。仅左侧栏。" } L10nKey::SettingsDiffPreviewFromCounts => "从侧栏计数打开 diff 预览", L10nKey::SettingsDiffPreviewFromCountsDesc => { @@ -574,9 +574,6 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAtEnd => "末尾", L10nKey::SettingsTop => "顶部", L10nKey::SettingsLeft => "左侧", - L10nKey::SettingsByRepo => "按仓库", - L10nKey::SettingsByRepoOrFolder => "按仓库或文件夹", - L10nKey::SettingsFlat => "平铺", L10nKey::SettingsPreset => "预设", L10nKey::SettingsPresetDesc => { "tmux 预设把窗格/标签页操作映射为前缀序列(例如 Ctrl-B 后按 C)。" @@ -901,7 +898,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { "显示托盘图标 托盘 菜单栏 状态 图标 show tray icon menu bar status" } L10nKey::SettingsSearchSidebarGroupingKeywords => { - "侧栏分组 标签页 分组 仓库 git 侧栏 文件夹 目录 sidebar grouping tabs repo repository folder directory" + "自动分组 标签页 分组 仓库 git ssh 主机 固定 未分组 侧栏 sidebar auto grouping tabs repo repository pinned pin host ungrouped" } L10nKey::SettingsSearchSshTabTitleKeywords => { "SSH 标签页 标题 名称 主机 主机名 配置 别名 固定 ssh tab title name host hostname profile alias pin" @@ -1022,6 +1019,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::FileTreeDownloadTooLarge => "超过 {limit} MB,请改用 scp 或 rsync 下载。", L10nKey::FileTreeContextOpen => "打开", L10nKey::FileTreeContextCdHere => "cd 到此处", + L10nKey::FileTreeContextPinAsGroup => "固定为分组", L10nKey::FileTreeContextInsertPath => "在终端中插入路径", L10nKey::FileTreeContextAttachAgent => "附加到 agent", L10nKey::FileTreeContextNewFile => "新建文件", @@ -1409,6 +1407,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::CmdNewWindow => "新建窗口", L10nKey::CmdNewWorktreeTab => "新建 worktree 标签页…", L10nKey::CmdNewWorktreeTabSubtitle => "在全新分支上独立检出", + L10nKey::CmdNewGroup => "新建分组", + L10nKey::CmdNewGroupSubtitle => "在侧栏新建一个空的固定分组", + L10nKey::CmdOpenFolderAsGroup => "将文件夹作为分组打开…", + L10nKey::CmdOpenFolderAsGroupSubtitle => "固定一个文件夹;在其中打开的标签页会加入", L10nKey::CmdRenameTab => "重命名标签页…", L10nKey::CmdSplitRight => "向右分屏", L10nKey::CmdSplitDown => "向下分屏", @@ -1815,12 +1817,19 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::TabMenuSplitHint => "按住 {key} 可分屏打开", L10nKey::TabUnnamedShell => "终端 {n}", L10nKey::ShellDefault => "默认", - L10nKey::SidebarScratchGroup => "草稿", + L10nKey::SidebarUngroupedGroup => "未分组", L10nKey::SidebarMoveToGroup => "移到分组", L10nKey::SidebarNewGroup => "新建分组…", - L10nKey::SidebarAutoGroup => "恢复自动分组", L10nKey::SidebarNewGroupName => "新建分组", L10nKey::SidebarRenameGroup => "重命名分组", + L10nKey::SidebarPinGroup => "固定分组", + L10nKey::SidebarUnpinGroup => "取消固定", + L10nKey::SidebarGroupNewTab => "新建标签页", + L10nKey::SidebarSetGroupFolder => "设置文件夹…", + L10nKey::SidebarUseCurrentTabFolder => "使用当前标签页的文件夹", + L10nKey::SidebarClearGroupFolder => "清除文件夹", + L10nKey::SidebarDeleteGroup => "删除分组", + L10nKey::SidebarDropToPin => "拖到此处固定", L10nKey::TabContextCloseTab => "关闭标签页", L10nKey::TabContextCloseTabsBelow => "关闭下方标签页", L10nKey::AppAgentHooksOpFailed => "失败:{error}", diff --git a/src/ui/machine_mirror.rs b/src/ui/machine_mirror.rs index 90245d01..405a427d 100644 --- a/src/ui/machine_mirror.rs +++ b/src/ui/machine_mirror.rs @@ -1,11 +1,11 @@ use std::collections::HashMap; use gpui::{App, Global}; +use tty7_core::core::group_key::AutoKey; use tty7_core::core::machine::{LayoutDelta, Machine, PaneRecord, Tab, TabId, Workspace}; use tty7_core::daemon::control::{ControlRequest, ReplyOk}; use tty7_core::host::HostId; -use crate::core::group_key::GroupKey; use crate::core::session::WorkspaceId; use crate::ui::i18n::{L10nKey, t}; @@ -300,11 +300,20 @@ fn apply(machine: &mut Machine, workspace: WorkspaceId, delta: &LayoutDelta) -> t.name = name.clone(); true } - LayoutDelta::TabRegrouped { tab, group } => { + LayoutDelta::TabRegrouped { + tab, + group, + last_auto, + } => { let Some(t) = ws.tabs.iter_mut().find(|t| t.id == *tab) else { return false; }; - t.sidebar_group = group.clone(); + t.group = *group; + t.last_auto = last_auto.clone(); + true + } + LayoutDelta::GroupsChanged { groups } => { + ws.groups = groups.clone(); true } LayoutDelta::TabMoved { tab, to } => { @@ -375,23 +384,31 @@ pub fn display_name_of(ws: &Workspace, panes: &[PaneRecord]) -> String { } pub fn subject_path_of(ws: &Workspace, panes: &[PaneRecord]) -> Option { - let mut counts: Vec<(String, usize)> = Vec::new(); - // Repo groups only. This answers with a *path*, and its callers treat it - // as one — `display_name_of` names the window after its last component. - // A custom group is a name the user typed, so putting one here would - // title a window `custom:work`, or chop `work/urgent` down to `urgent`. - // A workspace grouped entirely by hand falls through to a pane's cwd, - // which is a real path and is what the window showed before any of this. - for group in ws.tabs.iter().filter_map(|t| { - let key = GroupKey::decode(t.sidebar_group.as_deref()?)?; - Some(key.repo_root()?.to_string_lossy().into_owned()) - }) { - match counts.iter_mut().find(|(g, _)| *g == group) { - Some((_, n)) => *n += 1, - None => counts.push((group, 1)), + // Paths only. This answers with a *path*, and its callers treat it as one + // — `display_name_of` names the window after its last component. A label + // group is a name the user typed, so putting one here would chop + // `work/urgent` down to `urgent`. + // + // A pinned folder is the strongest statement of what a workspace is + // about, so the most common one wins. Failing that, the repo most of the + // auto-grouped tabs were last filed under — a worktree counts toward the + // repo it belongs to, since the hint is the repo home — which is what the + // title read before groups could be pinned. A tab kept in a pinned group + // casts no repo vote: it was put there by hand, as a hand-made group's tab + // was before. Failing both, a pane's cwd. + let folders = ws.tabs.iter().filter_map(|t| { + let group = ws.groups.get(t.group?)?; + group.folder.clone() + }); + let repos = ws.tabs.iter().filter_map(|t| { + if t.group.is_some_and(|g| ws.groups.contains(g)) { + return None; } - } - let dominant = counts.into_iter().max_by_key(|(_, n)| *n).map(|(g, _)| g); + match t.last_auto.as_ref()? { + AutoKey::Repo(home) => Some(home.to_string_lossy().into_owned()), + AutoKey::SshHost(_) => None, + } + }); let first_cwd = ws .tabs .iter() @@ -402,7 +419,22 @@ pub fn subject_path_of(ws: &Workspace, panes: &[PaneRecord]) -> Option { .find(|p| p.id == id) .and_then(|p| p.cwd.as_deref()) }); - dominant.or_else(|| first_cwd.map(str::to_string)) + most_common(folders) + .or_else(|| most_common(repos)) + .or_else(|| first_cwd.map(str::to_string)) +} + +/// The value seen most often, ties going to the one that reached the count +/// last — the rule `max_by_key` has always settled titles by here. +fn most_common(values: impl Iterator) -> Option { + let mut counts: Vec<(String, usize)> = Vec::new(); + for value in values { + match counts.iter_mut().find(|(v, _)| *v == value) { + Some((_, n)) => *n += 1, + None => counts.push((value, 1)), + } + } + counts.into_iter().max_by_key(|(_, n)| *n).map(|(v, _)| v) } pub fn display_name_for(cx: &App, client_ws: WorkspaceId) -> Option { @@ -506,6 +538,7 @@ pub fn pane_count(cx: &App, entry: &crate::core::session::WindowView) -> Option< #[cfg(test)] mod tests { + use tty7_core::core::group_key::PinnedGroup; use tty7_core::core::machine::{Axis, PaneNode, PaneSeed, Tab, TabId}; use super::*; @@ -928,7 +961,8 @@ mod tests { let restructured = Tab { id: tab_id, name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Split { axis: Axis::Vertical, ratio: 0.5, @@ -1034,11 +1068,13 @@ mod tests { "a pane's process title must not rename its workspace" ); - ws.tabs[0].sidebar_group = Some("/repo/tty7".into()); + let tty7 = PinnedGroup::folder(std::path::Path::new("/repo/tty7")); + ws.tabs[0].group = Some(tty7.id); + ws.groups.pinned.push(tty7); assert_eq!( display_name_of(&ws, &panes), "tty7", - "the repo group wins over the raw cwd" + "a pinned folder group wins over the raw cwd" ); ws.name = Some(" Release prep ".into()); @@ -1047,12 +1083,11 @@ mod tests { assert_eq!(display_name_of(&Workspace::default(), &[]), "Untitled"); } - /// A custom group is a name, not a path, and this answers with a path — + /// A label group is a name, not a path, and this answers with a path — /// its caller names the window after the last component. Left in, a - /// workspace grouped by hand would be titled `custom:work`, and one - /// grouped as `work/urgent` would be titled `urgent`. + /// workspace grouped as `work/urgent` would be titled `urgent`. #[test] - fn a_custom_group_is_not_a_subject_path() { + fn a_label_group_is_not_a_subject_path() { let mut ws = Workspace::default(); let panes = vec![PaneRecord { cwd: Some("/home/me/scratch".into()), @@ -1060,18 +1095,69 @@ mod tests { }]; ws.tabs = vec![leaf_tab(1)]; - ws.tabs[0].sidebar_group = Some("custom:work/urgent".into()); + let work = PinnedGroup::label("work/urgent"); + ws.tabs[0].group = Some(work.id); + ws.groups.pinned.push(work); assert_eq!( display_name_of(&ws, &panes), "scratch", "the cwd answers instead, the way it did before groups existed" ); - ws.tabs[0].sidebar_group = Some("/repo/tty7".into()); + let tty7 = PinnedGroup::folder(std::path::Path::new("/repo/tty7")); + ws.tabs[0].group = Some(tty7.id); + ws.groups.pinned.push(tty7); assert_eq!( display_name_of(&ws, &panes), "tty7", - "and a repo group still outranks the cwd" + "and a folder group still outranks the cwd" ); } + + /// With nothing pinned the title falls back the way it did before groups + /// could be pinned: to the repo most tabs are in — a worktree counting + /// toward its repo home, which is what the hint holds — and only then to + /// a pane's cwd. + #[test] + fn with_nothing_pinned_the_most_common_repo_names_the_workspace() { + let mut ws = Workspace::default(); + let panes = vec![PaneRecord { + cwd: Some("/home/me/scratch".into()), + ..PaneRecord::new(1) + }]; + ws.tabs = vec![leaf_tab(1), leaf_tab(2), leaf_tab(3)]; + assert_eq!(display_name_of(&ws, &panes), "scratch", "no hints: the cwd"); + + // Two tabs in tty7 (one of them a worktree, filed under its home) and + // one in api: tty7 wins. + ws.tabs[0].last_auto = Some(AutoKey::Repo("/repo/api".into())); + ws.tabs[1].last_auto = Some(AutoKey::Repo("/repo/tty7".into())); + ws.tabs[2].last_auto = Some(AutoKey::Repo("/repo/tty7".into())); + assert_eq!(display_name_of(&ws, &panes), "tty7"); + + // A tab kept in a label group casts no repo vote. + let work = PinnedGroup::label("work"); + ws.tabs[2].group = Some(work.id); + ws.groups.pinned.push(work); + ws.tabs[0].last_auto = Some(AutoKey::Repo("/repo/api".into())); + ws.tabs.push(leaf_tab(4)); + ws.tabs[3].last_auto = Some(AutoKey::Repo("/repo/api".into())); + assert_eq!(display_name_of(&ws, &panes), "api"); + } + + /// A pinned folder outranks any number of repo hints. + #[test] + fn a_pinned_folder_outranks_the_repo_majority() { + let mut ws = Workspace { + tabs: vec![leaf_tab(1), leaf_tab(2), leaf_tab(3)], + ..Default::default() + }; + for t in &mut ws.tabs { + t.last_auto = Some(AutoKey::Repo("/repo/tty7".into())); + } + let site = PinnedGroup::folder(std::path::Path::new("/w/site")); + ws.tabs[0].group = Some(site.id); + ws.groups.pinned.push(site); + assert_eq!(display_name_of(&ws, &[]), "site"); + } } diff --git a/src/ui/palette.rs b/src/ui/palette.rs index 50bde72e..bc7d16ff 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -29,6 +29,8 @@ pub enum CommandKind { ClosePane, RenameTab, NewWorktreeTab, + NewGroup, + OpenFolderAsGroup, CloseOtherTabs, CloseTabsToTheRight, CopyWorkingDirectory, @@ -143,6 +145,8 @@ impl CommandKind { ClosePane => "close-pane", RenameTab => "rename-tab", NewWorktreeTab => "new-worktree-tab", + NewGroup => "new-sidebar-group", + OpenFolderAsGroup => "open-folder-as-group", CloseOtherTabs => "close-other-tabs", CloseTabsToTheRight => "close-tabs-right", CopyWorkingDirectory => "copy-cwd", @@ -342,6 +346,8 @@ impl CommandKind { | SelectAllText | SendSelectionToAgent | SendGitDiffToAgent + | NewGroup + | OpenFolderAsGroup | UpdateLocalServer | UpdateRemoteServer | OpenWorkspacePicker diff --git a/src/ui/reorder.rs b/src/ui/reorder.rs index 01ff29df..c5f8f940 100644 --- a/src/ui/reorder.rs +++ b/src/ui/reorder.rs @@ -1,7 +1,7 @@ use gpui::{Axis, Bounds, Pixels, Point, Styled, px}; use std::cell::{Cell, RefCell}; use std::rc::Rc; -use tty7_core::core::group_key::GroupKey; +use tty7_core::core::group_key::{AutoKey, GroupId, GroupKey}; use tty7_core::core::machine::TabId; pub(crate) type ReorderState = Rc>>; @@ -63,28 +63,53 @@ pub(crate) fn clear_pending(state: &ReorderState) { if let Some(r) = state.borrow().as_ref() { r.pending.borrow_mut().take(); r.regroup.borrow_mut().take(); + r.pin.set(false); } } -/// Offer the custom group the pointer is currently over. +/// Where a tab being dragged in the sidebar would go if it were let go now, +/// when that is somewhere other than a new place in its own group. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Regroup { + /// Into this pinned group. + Into(GroupId), + /// Below the divider: out of its pinned group, back to auto grouping. + ToAuto, +} + +/// Offer the group the pointer is currently over. /// /// Reordering answers "where in this group", and this answers "which group" — /// two questions one drag can ask, so they are recorded side by side and /// resolved together when it lands. A drag held over a group it did not come /// from stops reordering (the surface it belongs to no longer sees the /// pointer) and starts offering this instead. -pub(crate) fn set_regroup(state: &ReorderState, key: GroupKey) { +pub(crate) fn set_regroup(state: &ReorderState, target: Regroup) { if let Some(r) = state.borrow().as_ref().filter(|r| !r.suspended.get()) { - *r.regroup.borrow_mut() = Some(key); + *r.regroup.borrow_mut() = Some(target); + } +} + +/// Offer to pin the auto group being dragged — its header is above the +/// divider. Cleared with everything else each frame, so a header carried back +/// down before it is let go reorders instead. +pub(crate) fn set_pin(state: &ReorderState) { + if let Some(r) = state.borrow().as_ref().filter(|r| !r.suspended.get()) { + r.pin.set(r.auto.is_some()); } } /// What a finished drag asks for. pub(crate) struct Landed { - /// A new order for the tabs, from the surface the drag ran over. + /// A new order, from the surface the drag ran over — of the tabs, or for + /// [`Surface::PinnedGroups`] of the pinned groups. pub(crate) order: Option>, + /// The surface `order` is an order of. + pub(crate) surface: Option, /// A tab to put in another group, from the group it was held over. - pub(crate) regroup: Option<(TabId, GroupKey)>, + pub(crate) regroup: Option<(TabId, Regroup)>, + /// An auto group to pin, from its header being let go above the divider. + pub(crate) pin: Option, } /// Ends the drag and answers what it was asking for when it ended. @@ -92,13 +117,18 @@ pub(crate) fn take_landed(state: &ReorderState) -> Landed { let Some(r) = state.borrow_mut().take() else { return Landed { order: None, + surface: None, regroup: None, + pin: None, }; }; let regroup = r.regroup.into_inner(); + let pin = r.pin.get().then_some(r.auto).flatten(); Landed { order: r.pending.into_inner(), + surface: Some(r.surface), regroup: r.tab.zip(regroup), + pin, } } @@ -111,6 +141,12 @@ pub(crate) fn dragged_tab(state: &ReorderState) -> Option { state.borrow().as_ref()?.tab } +/// The auto group a *sidebar header* drag picked up, if it is one. +pub(crate) fn dragged_auto_group(state: &ReorderState) -> Option { + let state = state.borrow(); + state.as_ref().filter(|r| !r.suspended.get())?.auto.clone() +} + /// The tab a *sidebar row* drag picked up. /// /// `None` for a drag that started anywhere else. The strip drags tabs too, @@ -142,7 +178,13 @@ pub(crate) fn suspend(state: &ReorderState, yes: bool) { pub(crate) enum Surface { Strip, SidebarRows(Option), + /// The auto groups' headers, below the divider. SidebarGroups, + /// The pinned groups' headers, above it. A surface of its own so a + /// pinned header can only be dropped among the pinned ones: the two + /// halves of the sidebar are ordered by different things — the user's + /// hand above, first appearance below. + PinnedGroups, } pub(crate) struct Reorder { @@ -155,9 +197,14 @@ pub(crate) struct Reorder { prev: Cell, generation: Cell, pending: RefCell>>, - /// The custom group this drag is being held over, when the pointer has - /// left the group the tab came from. - regroup: RefCell>, + /// The group this drag is being held over, when the pointer has left the + /// group the tab came from. + regroup: RefCell>, + /// The auto group whose header this drag picked up, for the one gesture + /// that pins: carrying it above the divider. + auto: Option, + /// Set on the frames the carried auto header is above the divider. + pin: Cell, /// The tab this drag picked up, for the surfaces that drag tabs. `None` on /// a surface that drags something else — a sidebar group, say, which is /// several tabs and cannot be merged into one. @@ -185,6 +232,8 @@ impl Reorder { generation: Cell::new(0), pending: RefCell::new(None), regroup: RefCell::new(None), + auto: None, + pin: Cell::new(false), tab: None, suspended: Cell::new(false), } @@ -196,6 +245,12 @@ impl Reorder { self } + /// Names the auto group whose header this drag is carrying. + pub(crate) fn of_auto(mut self, key: AutoKey) -> Self { + self.auto = Some(key); + self + } + pub(crate) fn covers(&self, surface: &Surface, len: usize) -> bool { self.surface == *surface && self.rects.len() == len && self.from < len } @@ -448,18 +503,18 @@ mod tests { #[test] fn a_regroup_lasts_only_as_long_as_the_pointer_is_over_the_group() { let tab = TabId::new(); - let work = GroupKey::custom("work").expect("non-blank"); + let work = Regroup::Into(GroupId::new()); let fresh = || Some(column(3, 30., 2., 0).of_tab(tab)); let state: ReorderState = Rc::new(RefCell::new(fresh())); clear_pending(&state); - set_regroup(&state, work.clone()); + set_regroup(&state, work); clear_pending(&state); assert_eq!(take_landed(&state).regroup, None, "the frame moved on"); *state.borrow_mut() = fresh(); clear_pending(&state); - set_regroup(&state, work.clone()); + set_regroup(&state, work); assert_eq!(take_landed(&state).regroup, Some((tab, work))); } @@ -469,10 +524,59 @@ mod tests { fn a_drag_with_no_tab_never_lands_in_a_group() { let state: ReorderState = Rc::new(RefCell::new(Some(column(3, 30., 2., 0)))); clear_pending(&state); - set_regroup(&state, GroupKey::custom("work").expect("non-blank")); + set_regroup(&state, Regroup::Into(GroupId::new())); assert_eq!(take_landed(&state).regroup, None); } + /// Pinning is offered frame by frame, like a regroup: a header carried + /// above the divider and back down before it is let go only reorders. + #[test] + fn a_pin_lasts_only_as_long_as_the_header_is_above_the_divider() { + let key = AutoKey::Repo("/w/r".into()); + let fresh = || Some(column(3, 30., 2., 0).of_auto(key.clone())); + + let state: ReorderState = Rc::new(RefCell::new(fresh())); + clear_pending(&state); + set_pin(&state); + clear_pending(&state); + assert_eq!(take_landed(&state).pin, None, "carried back down"); + + *state.borrow_mut() = fresh(); + clear_pending(&state); + set_pin(&state); + assert_eq!(take_landed(&state).pin, Some(key)); + } + + /// A finished drag says which surface its order is for: the pinned + /// headers' order is the list's, not the tabs'. + #[test] + fn a_landing_names_the_surface_its_order_is_for() { + let r = Reorder::new( + Surface::PinnedGroups, + 0, + column(3, 30., 2., 0).rects, + Axis::Vertical, + px(2.), + point(px(0.), px(0.)), + ); + let state: ReorderState = Rc::new(RefCell::new(Some(r))); + clear_pending(&state); + set_pending(&state, &Surface::PinnedGroups, vec![1, 0, 2]); + let landed = take_landed(&state); + assert_eq!(landed.surface, Some(Surface::PinnedGroups)); + assert_eq!(landed.order, Some(vec![1, 0, 2])); + } + + /// Only an auto header can be pinned by dragging; a drag carrying + /// anything else offers nothing. + #[test] + fn a_drag_with_no_auto_group_never_pins() { + let state: ReorderState = Rc::new(RefCell::new(Some(column(3, 30., 2., 0)))); + clear_pending(&state); + set_pin(&state); + assert_eq!(take_landed(&state).pin, None); + } + #[test] fn begin_frame_bumps_the_generation_only_on_change() { let r = column(3, 30., 2., 0); diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 6e6ecdf5..bd2ebc97 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -901,7 +901,7 @@ impl SearchEntry { L10nKey::SettingsScrollback => "scrollback_limit", L10nKey::SettingsNewTabPosition => "new_tab_position", L10nKey::SettingsTabBarPosition => "tab_bar_position", - L10nKey::SettingsSidebarGrouping => "sidebar_grouping", + L10nKey::SettingsSidebarGrouping => "sidebar_auto_grouping", L10nKey::SettingsDiffPreviewFromCounts => "sidebar_diff_preview", L10nKey::SettingsSshTabTitle => "ssh_tab_title", L10nKey::SettingsNotifyOnCommandFinish => "notify_on_command_finish", @@ -1053,7 +1053,9 @@ impl SearchEntry { L10nKey::SettingsScrollback => cfg.scrollback_limit != defaults.scrollback_limit, L10nKey::SettingsNewTabPosition => cfg.new_tab_position != defaults.new_tab_position, L10nKey::SettingsTabBarPosition => cfg.tab_bar_position != defaults.tab_bar_position, - L10nKey::SettingsSidebarGrouping => cfg.sidebar_grouping != defaults.sidebar_grouping, + L10nKey::SettingsSidebarGrouping => { + cfg.sidebar_auto_grouping != defaults.sidebar_auto_grouping + } L10nKey::SettingsDiffPreviewFromCounts => { cfg.sidebar_diff_preview != defaults.sidebar_diff_preview } @@ -7691,16 +7693,12 @@ impl Tty7App { TabBarPosition::Left => 1, }; let sidebar_diff_preview = cfg.sidebar_diff_preview; + let sidebar_auto_grouping = cfg.sidebar_auto_grouping; let ssh_tab_title_idx = match cfg.ssh_tab_title { SshTabTitle::Dynamic => 0, SshTabTitle::ProfileName => 1, SshTabTitle::Hostname => 2, }; - let sidebar_grouping_idx = match cfg.sidebar_grouping { - crate::core::config::SidebarGrouping::Repo => 0, - crate::core::config::SidebarGrouping::RepoOrDirectory => 1, - crate::core::config::SidebarGrouping::None => 2, - }; let notify_idx = match cfg.notify_on_command_finish { NotifyMode::Never => 0, NotifyMode::Unfocused => 1, @@ -7808,24 +7806,12 @@ impl Tty7App { .checked(sidebar_diff_preview) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_sidebar_diff_preview(*on, cx))) .into_any_element(); - let sidebar_grouping_radio = self.segmented( - "wt-sidebar-grouping", - &[ - t(L10nKey::SettingsByRepo), - t(L10nKey::SettingsByRepoOrFolder), - t(L10nKey::SettingsFlat), - ], - sidebar_grouping_idx, - cx, - |this, ix, _w, cx| { - let grouping = match ix { - 0 => crate::core::config::SidebarGrouping::Repo, - 1 => crate::core::config::SidebarGrouping::RepoOrDirectory, - _ => crate::core::config::SidebarGrouping::None, - }; - this.set_sidebar_grouping(grouping, cx); - }, - ); + let sidebar_grouping_switch = crate::ui::theme::switch("wt-sidebar-auto-grouping", cx) + .checked(sidebar_auto_grouping) + .on_click( + cx.listener(|this, on: &bool, _w, cx| this.set_sidebar_auto_grouping(*on, cx)), + ) + .into_any_element(); let ssh_tab_title_radio = self.segmented( "wt-ssh-tab-title", @@ -7892,7 +7878,7 @@ impl Tty7App { .child(self.settings_row( t(L10nKey::SettingsSidebarGrouping), t(L10nKey::SettingsSidebarGroupingDesc), - sidebar_grouping_radio, + sidebar_grouping_switch, cx, )) .child(self.settings_row( @@ -9938,7 +9924,7 @@ mod tests { "Terminal bell", "Report mouse to apps", "Open files with", - "Sidebar grouping", + "Auto grouping", "Tab completion", "Command history search", "Dim inactive panes", diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 7ef995d4..55d5db75 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -1,7 +1,8 @@ use gpui::{ - Animation, AnimationExt as _, AnyElement, Axis, Bounds, Context, Div, FontWeight, MouseButton, - MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, SharedString, Stateful, Window, canvas, - deferred, div, ease_out_quint, linear_color_stop, linear_gradient, prelude::*, px, + Animation, AnimationExt as _, AnyElement, Axis, Bounds, Context, Div, ExternalPaths, + FontWeight, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, SharedString, + Stateful, Window, canvas, deferred, div, ease_out_quint, linear_color_stop, linear_gradient, + prelude::*, px, }; use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::input::{Input, InputEvent}; @@ -12,8 +13,10 @@ use std::rc::Rc; use std::path::{Path, PathBuf}; -use crate::core::config::{Config, SidebarGrouping}; -use crate::core::group_key::{GroupKey, collapse_key}; +use crate::core::config::Config; +use crate::core::group_key::{ + AutoKey, GroupId, GroupKey, PinnedGroup, WorkspaceGroups, auto_key, place, +}; use crate::terminal::git_status::GitStatusCache; use crate::ui::app::{TITLE_BAR_HEIGHT, Tab, Tty7App}; use crate::ui::hints::tab_badge_label; @@ -60,7 +63,7 @@ mod row_metrics { pub(super) const BRANCH_ICON: f32 = 11.; /// `pl_2` + `pr_1p5` on a group header. pub(super) const HEADER_PAD: f32 = 8. + 6.; - /// The chevron a header opens with, and the asterisk that marks a custom + /// The chevron a header opens with, and the pin that marks a folder /// group: both `xsmall` icons, which resolve to 12px. pub(super) const HEADER_ICON: f32 = 12.; @@ -91,7 +94,7 @@ const ROW_CWD_FLOOR: f32 = 24.; /// rows share. The branch takes what it wants up to half the line, and the /// heading keeps the rest — so a long branch can no longer crush the name /// (flex used to hand the overflow to them in proportion to what each asked -/// for, which gave the longer string the smaller cut), and a long custom name +/// for, which gave the longer string the smaller cut), and a long group name /// cannot crush the branch in return. `git_want` is `None` for a header with /// no shared branch on it, which then owns the whole line. fn header_name_avail(avail: f32, git_want: Option) -> f32 { @@ -230,12 +233,15 @@ impl Tty7App { // same way a pane dropped here is. Blanked and written again below // so a group that folds or filters away stops accepting drops. let over_group = self.sidebar_regroup_target(window); + // Taken rather than read, for the same reason: last frame's divider is + // what a drag is measured against, and the one drawn below replaces it. + let divider_last = self.sidebar_divider.take(); let lifting_row = crate::ui::reorder::dragged_sidebar_tab(&self.reorder).is_some(); // Offered every frame the pointer is over a group, and cleared with // the rest of the drag's pending state on the frames it is not — so // letting go anywhere else drops on nothing. - if let Some(key) = over_group.clone() { - crate::ui::reorder::set_regroup(&self.reorder, key); + if let Some(target) = over_group { + crate::ui::reorder::set_regroup(&self.reorder, target); } self.sidebar_group_slots.borrow_mut().clear(); // Every group drops itself when its rows filter out, so a query that @@ -244,6 +250,11 @@ impl Tty7App { let mut list = v_flex() .id("tab-sidebar-list") + // A folder dragged in from Finder is pinned as a group of its own. + .drag_over::(|s, _, _, cx| s.bg(cx.theme().drag_border.opacity(0.06))) + .on_drop(cx.listener(|this, paths: &ExternalPaths, _window, cx| { + this.pin_dropped_folders(paths.paths(), cx) + })) .track_scroll(&self.sidebar_scroll) .flex_1() .min_h_0() @@ -254,14 +265,12 @@ impl Tty7App { .gap_0p5(); let keys: Rc>> = Rc::new(self.sidebar_group_keys(cx)); - let sections = sidebar_sections(&keys); + let groups = &self.sidebar_groups; + let sections = sidebar_sections(&keys, groups); // A search outranks a fold. Typing something that matches a row inside // a folded group has to show that row — a box that says nothing // matches while the match sits behind a chevron is just lying. - let folded_keys: Vec = match query.is_empty() { - true => cx.global::().sidebar_collapsed_groups.clone(), - false => Vec::new(), - }; + let folds_apply = query.is_empty(); // ⌘N runs ActivateTabN, which goes through `activate_visual` — the // Nth row as the sidebar lays it out, not the Nth tab in `self.tabs`. @@ -343,13 +352,52 @@ impl Tty7App { cx.theme().muted_foreground, cx.theme().sidebar, ); - let rendered = |ix: &usize| !visible_by_section[*ix].is_empty(); - // Every section that owns a key — a repo root or a custom name — draws - // a header, and a header is what there is to grab, so these are the - // slots the group-reorder surface runs over. Scratch is excluded: it - // is where the keyless tabs fall, and it always sits last. + // A pinned group is drawn with no tabs in it — it is kept, and the + // row it grows says how to fill it — except while a search is + // narrowing the list, where a group with nothing matching is noise. + let rendered = |ix: &usize| { + !visible_by_section[*ix].is_empty() + || (query.is_empty() && sections[*ix].pinned().is_some()) + }; + // Pinned headers reorder among themselves, by hand; that order is the + // workspace's and is written back whole when a drag lands. + let pinned_slots: Vec = (0..sections.len()) + .filter(|&ix| sections[ix].pinned().is_some()) + .filter(rendered) + .collect(); + let pinned_rects: Rc>>> = + Rc::new(RefCell::new(vec![Bounds::default(); pinned_slots.len()])); + let pinned_preview = reorder::preview( + &self.reorder, + &Surface::PinnedGroups, + pinned_slots.len(), + pointer, + ); + let pinned_display: Vec = match &pinned_preview { + Some(p) => { + let all: Vec = groups.pinned.iter().map(|g| g.id).collect(); + let shown: Vec = pinned_slots + .iter() + .filter_map(|&ix| sections[ix].pinned()) + .collect(); + if let Some(order) = reordered_pinned(&all, &shown, p.from, p.target) { + reorder::set_pending(&self.reorder, &Surface::PinnedGroups, order); + } + p.order.clone() + } + None => (0..pinned_slots.len()).collect(), + }; + // Every auto group draws a header, and a header is what there is to + // grab, so these are the slots the auto-group surface runs over. + // Ungrouped is excluded: it is where the keyless tabs fall, and it + // always sits last. let keyed_slots: Vec = (0..sections.len()) - .filter(|&ix| sections[ix].key.is_some()) + .filter(|&ix| { + sections[ix] + .key + .as_ref() + .is_some_and(|k| k.auto().is_some()) + }) .filter(rendered) .collect(); let keyed_groups = keyed_slots.len(); @@ -361,9 +409,9 @@ impl Tty7App { keyed_groups, pointer, ); - let keyed_keys: Vec = keyed_slots + let keyed_keys: Vec = keyed_slots .iter() - .filter_map(|&ix| sections[ix].key.clone()) + .filter_map(|&ix| sections[ix].key.as_ref()?.auto().cloned()) .collect(); let slot_display: Vec = match &group_preview { Some(p) => { @@ -376,26 +424,62 @@ impl Tty7App { } None => (0..keyed_groups).collect(), }; - let mut blocks: Vec<(Option, usize)> = slot_display + let mut blocks: Vec<(Option, usize)> = pinned_display .into_iter() - .map(|slot| (Some(slot), keyed_slots[slot])) + .map(|slot| (Some(HeaderSlot::Pinned(slot)), pinned_slots[slot])) .collect(); + let first_unpinned = blocks.len(); + blocks.extend( + slot_display + .into_iter() + .map(|slot| (Some(HeaderSlot::Auto(slot)), keyed_slots[slot])), + ); blocks.extend( (0..sections.len()) .filter(|&ix| sections[ix].key.is_none()) .filter(rendered) .map(|ix| (None, ix)), ); + // The divider: kept groups above it, derived ones below. Drawn + // whenever something is kept, and while an auto header is in the air + // even when nothing is yet — carrying it above the divider is how it + // gets pinned, so there has to be a divider to carry it above. + let lifted_auto = reorder::dragged_auto_group(&self.reorder); + let show_divider = !groups.pinned.is_empty() || lifted_auto.is_some(); + let pin_hover = lifted_auto.is_some() + && divider_last.is_some_and(|d| pointer.y < d.origin.y + d.size.height); + if pin_hover { + reorder::set_pin(&self.reorder); + } + let divider_lit = pin_hover || over_group == Some(reorder::Regroup::ToAuto); + let divider_zone = groups.pinned.is_empty(); + let mut divider_drawn = false; + // Whether the tab in the air is kept in a pinned group — then + // everything below the divider is somewhere it can go. + let lifted_is_kept = reorder::dragged_sidebar_tab(&self.reorder).is_some_and(|id| { + self.tabs + .iter() + .find(|t| t.tree_id.get() == id) + .and_then(|t| t.group.get()) + .is_some_and(|g| groups.contains(g)) + }); + let workspace_is_local = !crate::core::session::WorkspaceStore::all(cx) + .get(self.workspace) + .is_some_and(|w| w.is_remote()); - for (group_slot, group_ix) in blocks { + for (n, (group_slot, group_ix)) in blocks.into_iter().enumerate() { + if n == first_unpinned && show_divider { + list = list.child(self.sidebar_divider(divider_lit, divider_zone, cx)); + divider_drawn = true; + } let section = §ions[group_ix]; let group_key = section.key.clone(); // Only a group that draws a header can be folded — there is // nothing to click otherwise, and the one headerless section (the - // whole sidebar, when grouping is off) must never answer to the - // scratch group's key. + // list below the pinned groups, when nothing else is there) must + // never answer to Ungrouped's fold. let folded = - section.name.is_some() && folded_keys.contains(&collapse_key(group_key.as_ref())); + section.name.is_some() && folds_apply && groups.is_folded(group_key.as_ref()); let mut rows: Vec>> = Vec::new(); // The header keeps counting every row the group has; folding only // stops them being drawn. Nothing downstream then registers a @@ -1028,14 +1112,15 @@ impl Tty7App { })); } - if row_count == 0 { + // An empty pinned group is still drawn: it is kept until deleted. + if row_count == 0 && section.pinned().is_none() { 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(&keys, groups, &group_key, &visible_tabs, p.from, p.target) { reorder::set_pending( &self.reorder, @@ -1082,16 +1167,22 @@ impl Tty7App { .into_any_element(), }) .collect(); - // A custom group carries a pin. It is the only thing separating + // A pinned group carries a mark. It is the only thing separating // it on sight from a derived one — they behave differently (a // `cd` moves a tab out of a repo group and never out of this - // one), and a custom group named after a real repo would - // otherwise print a header identical to that repo's. - let pinned = group_key.as_ref().is_some_and(GroupKey::is_custom); + // one), and a group pinned on a real repo would otherwise print a + // header identical to that repo's. + let pinned_id = section.pinned(); + let pinned = pinned_id.is_some(); + let pinned_folder: Option = pinned_id + .and_then(|id| groups.get(id)) + .and_then(|g| g.folder.clone()) + .map(SharedString::from); + let auto_key_here = group_key.as_ref().and_then(GroupKey::auto).cloned(); let renaming_group = self .group_rename .as_ref() - .filter(|r| Some(&r.key) == group_key.as_ref()) + .filter(|r| Some(r.group) == pinned_id) .map(|r| r.input.clone()); let header = section.name.clone().map(|name| { // The header packs a heading and the branch its whole group @@ -1107,7 +1198,7 @@ impl Tty7App { // row already elides its own. let ts = window.text_system(); let mut avail = row_metrics::header_budget(width); - if pinned { + if pinned_folder.is_some() { avail -= row_metrics::HEADER_ICON + row_metrics::META_GAP; } let count_label = row_count.to_string(); @@ -1130,8 +1221,11 @@ impl Tty7App { let name_avail = header_name_avail(avail, git_want); let label = elide_label(&ts, &header_font, header_size, &name, name_avail); let name_w = measure_text(&ts, &header_font, header_size, &label); + let hover_group = SharedString::from(format!("sidebar-group-{group_ix}")); let bar = h_flex() .id(("sidebar-group", group_ix)) + .group(hover_group.clone()) + .relative() .w_full() .items_center() .gap_1p5() @@ -1150,19 +1244,31 @@ impl Tty7App { move |this, _, _window, cx| this.toggle_sidebar_group(key.as_ref(), cx) })) .when_some(group_slot, |header, slot| { + let (surface, slot, slots) = match slot { + HeaderSlot::Pinned(s) => { + (Surface::PinnedGroups, s, pinned_rects.clone()) + } + HeaderSlot::Auto(s) => (Surface::SidebarGroups, s, group_slots.clone()), + }; + let auto = auto_key_here.clone(); crate::ui::reorder::cursor_grab(header).on_drag(DragGroup, { let state = self.reorder.clone(); - let slots = group_slots.clone(); move |_drag, grab, _window, cx| { cx.stop_propagation(); - *state.borrow_mut() = Some(Reorder::new( - Surface::SidebarGroups, + let reorder = Reorder::new( + surface.clone(), slot, slots.borrow().clone(), Axis::Vertical, px(ROW_GAP), grab, - )); + ); + // An auto header says which group it is, so + // letting it go above the divider can pin it. + *state.borrow_mut() = Some(match auto.clone() { + Some(key) => reorder.of_auto(key), + None => reorder, + }); cx.new(|_| DragGroup) } }) @@ -1177,13 +1283,37 @@ impl Tty7App { .child(Icon::new(IconName::ChevronRight).xsmall()), ) }) - .when(pinned, |header| { - header.child( - div() - .flex_shrink_0() - .child(Icon::new(IconName::Asterisk).xsmall()), - ) - }) + // The pin marks a folder group: kept, and keeping a folder. + // It is also the way to stop keeping it — a click unpins, + // and the folder's tabs fall back to the groups their cwds + // resolve to. A label group has no such mark: it sits above + // the divider, and there is no folder to fall back on. + .when_some( + pinned_id.filter(|_| pinned_folder.is_some()), + |header, id| { + header.child( + div() + .id(("sidebar-group-unpin", group_ix)) + .flex_shrink_0() + .cursor_pointer() + .hover(|s| s.text_color(cx.theme().foreground)) + .child(Icon::empty().path("icons/pin.svg").xsmall()) + .tooltip(|window, cx| { + gpui_component::tooltip::Tooltip::new(t( + L10nKey::SidebarUnpinGroup, + )) + .build(window, cx) + }) + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation() + }) + .on_click(cx.listener(move |this, _, _window, cx| { + cx.stop_propagation(); + this.delete_group(id, cx); + })), + ) + }, + ) .child(match renaming_group { Some(input) => div() .id(("sidebar-group-rename", group_ix)) @@ -1201,6 +1331,7 @@ impl Tty7App { // backstop for a face that measures wider than it // paints; the name no longer gives room to the branch. None => div() + .id(("sidebar-group-name", group_ix)) .flex_shrink_0() .min_w_0() .truncate() @@ -1210,6 +1341,14 @@ impl Tty7App { // counts beside it are the metadata. .text_color(cx.theme().foreground) .child(label) + // A folder group may have been renamed to anything; + // the folder it keeps is what the name stands for. + .when_some(pinned_folder.clone(), |name, folder| { + name.tooltip(move |window, cx| { + gpui_component::tooltip::Tooltip::new(folder.clone()) + .build(window, cx) + }) + }) .into_any_element(), }) .when_some(shared_git, |bar, shared| { @@ -1299,34 +1438,41 @@ impl Tty7App { // is what a shut group has instead of them. .when(folded, |bar| { bar.child(div().flex_shrink_0().child(count_label)) - }); + }) + .children(self.header_actions(group_ix, group_key.clone(), hover_group, cx)); // Renaming is offered on a menu rather than a double click: // the first click of a double would fold the group, so the - // name would be edited on a box that just shut. A repo group - // gets no menu — it is named after its root, and a rename - // there could only lie about where its tabs are. + // name would be edited on a box that just shut. An auto group + // offers no rename — it is named after its repo or host, and a + // rename there could only lie about where its tabs are; pin it + // first, and it is the user's to name. // // Attached last and erased to `AnyElement`, because the menu - // wrapper changes the element's type and the two arms have to + // wrapper changes the element's type and the arms have to // agree. - match (pinned, group_key.clone()) { - (true, Some(key)) => { - let app = cx.entity().downgrade(); - bar.context_menu(move |menu, _window, _cx| { - let app = app.clone(); - let key = key.clone(); - menu.item(PopupMenuItem::new(t(L10nKey::SidebarRenameGroup)).on_click( - move |_, window, cx| { - let key = key.clone(); - let _ = app.update(cx, |this, cx| { - this.start_group_rename(key, window, cx) - }); - }, - )) + let app = cx.entity().downgrade(); + match (pinned_id, auto_key_here.clone()) { + (Some(id), _) => { + let has_folder = pinned_folder.is_some(); + bar.context_menu(move |menu, window, cx| { + pinned_header_menu( + menu, + id, + has_folder, + workspace_is_local, + &app, + window, + cx, + ) }) .into_any_element() } - _ => bar.into_any_element(), + (None, Some(key)) => bar + .context_menu(move |menu, _window, _cx| { + auto_header_menu(menu, key.clone(), &app) + }) + .into_any_element(), + (None, None) => bar.into_any_element(), } }); @@ -1335,50 +1481,91 @@ impl Tty7App { // that cannot fade back. Until a drag is under way they look // alike, and this is where a user finds out which is which // without being told. - let takes_drops = group_key.as_ref().is_some_and(GroupKey::is_custom); + let takes_drops = pinned || (lifted_is_kept && n >= first_unpinned); + let (preview, slot) = match group_slot { + Some(HeaderSlot::Pinned(s)) => (pinned_preview.as_ref(), Some(s)), + Some(HeaderSlot::Auto(s)) => (group_preview.as_ref(), Some(s)), + None => (None, None), + }; + // An empty pinned group says how to fill it, in the place its + // rows would be: a tab can be dragged onto it, or opened here. + let empty_row = (row_count == 0 && !folded) + .then_some(pinned_id) + .flatten() + .map(|id| { + h_flex() + .id(("sidebar-group-empty", group_ix)) + .w_full() + .h(px(ROW_HEIGHT)) + .items_center() + .gap_2() + .pl_2() + .rounded(crate::ui::rounding::CARD_RADIUS) + .cursor_pointer() + .text_sm() + .text_color(cx.theme().muted_foreground) + .hover(|s| { + s.bg(gpui::rgb(sf.hover)) + .text_color(cx.theme().sidebar_foreground) + }) + .child( + div() + .flex() + .flex_shrink_0() + .items_center() + .justify_center() + .size(px(row_metrics::AVATAR)) + .child(Icon::empty().path("icons/plus.svg").xsmall()), + ) + .child(t(L10nKey::SidebarGroupNewTab)) + .on_click(cx.listener(move |this, _, window, cx| { + this.new_tab_in_group(GroupKey::Pinned(id), window, cx) + })) + }); let block = v_flex() .w_full() .gap(px(ROW_GAP)) - .when( - group_preview - .as_ref() - .is_some_and(|p| Some(p.from) == group_slot), - |b| b.opacity(0.75), - ) - .when(lifting_row && !takes_drops, |b| b.opacity(0.4)) - .when(over_group.is_some() && over_group == group_key, |b| { - b.rounded_md().bg(cx.theme().drag_border.opacity(0.15)) + .when(preview.is_some_and(|p| Some(p.from) == slot), |b| { + b.opacity(0.75) }) + .when(lifting_row && !takes_drops, |b| b.opacity(0.4)) + .when( + pinned_id.is_some_and(|id| over_group == Some(reorder::Regroup::Into(id))), + |b| b.rounded_md().bg(cx.theme().drag_border.opacity(0.15)), + ) .children(header) .children(rows) - .when_some(group_slot, |block, slot| { - block.child( - canvas( - { - let slots = group_slots.clone(); - let landing = self.sidebar_group_slots.clone(); - // Only a custom group is recorded, so a drag - // looking for somewhere to land finds nothing - // over a repo group or over Scratch. - let key = group_key.clone().filter(GroupKey::is_custom); - move |bounds, _window, _cx| { - if let Some(s) = slots.borrow_mut().get_mut(slot) { - *s = bounds; - } - if let Some(key) = key.clone() { - landing.borrow_mut().push((key, bounds)); - } + .children(empty_row) + .child( + canvas( + { + let slots = match group_slot { + Some(HeaderSlot::Pinned(_)) => pinned_rects.clone(), + _ => group_slots.clone(), + }; + let landing = self.sidebar_group_slots.clone(); + // Only a pinned group is recorded, so a drag + // looking for somewhere to land finds nothing over + // an auto group or over Ungrouped. + move |bounds, _window, _cx| { + if let Some(slot) = slot + && let Some(s) = slots.borrow_mut().get_mut(slot) + { + *s = bounds; } - }, - |_, _, _, _| {}, - ) - .absolute() - .inset_0(), + if let Some(id) = pinned_id { + landing.borrow_mut().push((id, bounds)); + } + } + }, + |_, _, _, _| {}, ) - }); + .absolute() + .inset_0(), + ); any_rows = true; - list = list.child(match (&group_preview, group_slot) { + list = list.child(match (preview, slot) { (Some(p), Some(slot)) if p.from == slot => { deferred(block.relative().top(p.held)).into_any_element() } @@ -1399,6 +1586,9 @@ impl Tty7App { _ => block.into_any_element(), }); } + if show_divider && !divider_drawn { + list = list.child(self.sidebar_divider(divider_lit, divider_zone, cx)); + } if !any_rows && !query.is_empty() { list = list.child( @@ -1633,6 +1823,142 @@ impl Tty7App { .child(handle) } + /// The line between the kept groups and the derived ones, recording where + /// it was drawn so a drag next frame can tell which side it is on. + /// + /// With nothing pinned yet it only appears while an auto header is being + /// carried, and then as a drop zone rather than a hairline: a one-pixel + /// line at the very top of the list is not something anyone could aim + /// above. `lit` while letting go would pin the header, or hand a kept tab + /// back to auto grouping. + fn sidebar_divider(&self, lit: bool, zone: bool, cx: &Context) -> AnyElement { + let bounds = self.sidebar_divider.clone(); + let ink = match lit { + true => cx.theme().drag_border, + false => cx.theme().sidebar_border, + }; + let body = match zone { + true => h_flex() + .h(px(ROW_HEIGHT)) + .w_full() + .items_center() + .justify_center() + .rounded(crate::ui::rounding::CARD_RADIUS) + .border_1() + .border_color(ink) + .when(lit, |d| d.bg(cx.theme().drag_border.opacity(0.15))) + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(t(L10nKey::SidebarDropToPin)) + .into_any_element(), + false => div() + .w_full() + .h(px(if lit { 2. } else { 1. })) + .bg(ink) + .into_any_element(), + }; + v_flex() + .id("sidebar-divider") + .relative() + .w_full() + .px_2() + .pt(px(10.)) + .pb(px(2.)) + .child( + canvas(move |b, _window, _cx| bounds.set(Some(b)), |_, _, _, _| {}) + .absolute() + .inset_0(), + ) + .child(body) + .into_any_element() + } + + /// The buttons a header shows on hover, laid over its right end: pin for + /// an auto group, and "+" to open a tab in the group. `None` for + /// Ungrouped, which has neither. + /// + /// Laid over the branch rather than beside it, the way a row's close + /// button covers its badge, so a header does not give up width it would + /// only use while the pointer is on it. Acted on at the press, as a row's + /// diff counts are: the header folds on click and picks itself up on a + /// drag, and swallowing the press is what keeps both from happening too. + fn header_actions( + &self, + group_ix: usize, + key: Option, + hover: SharedString, + cx: &mut Context, + ) -> Option { + let key = key?; + let backing = crate::ui::theme::workspace_surface_color(cx); + let mut fade = backing; + fade.a = 0.; + let muted = cx.theme().muted_foreground; + let ink = cx.theme().foreground; + let button = |id: &'static str, path: &'static str, tip: L10nKey| { + div() + .id((id, group_ix)) + .flex_shrink_0() + .p(px(2.)) + .rounded_sm() + .cursor_pointer() + .text_color(muted) + .hover(move |s| s.text_color(ink)) + .child(Icon::empty().path(path).xsmall()) + .tooltip(move |window, cx| { + gpui_component::tooltip::Tooltip::new(t(tip)).build(window, cx) + }) + }; + let mut buttons = h_flex().gap_0p5().bg(backing); + if let Some(auto) = key.auto().cloned() { + buttons = buttons.child( + button( + "sidebar-group-pin", + "icons/pin.svg", + L10nKey::SidebarPinGroup, + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _: &MouseDownEvent, _window, cx| { + cx.stop_propagation(); + this.pin_auto_group(auto.clone(), cx); + }), + ), + ); + } + if !matches!(key, GroupKey::Auto(AutoKey::SshHost(_))) { + buttons = buttons.child( + button( + "sidebar-group-add", + "icons/plus.svg", + L10nKey::SidebarGroupNewTab, + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _: &MouseDownEvent, window, cx| { + cx.stop_propagation(); + this.new_tab_in_group(key.clone(), window, cx); + }), + ), + ); + } + Some( + h_flex() + .absolute() + .right(px(4.)) + .bottom(px(2.)) + .opacity(0.) + .group_hover(hover, |s| s.opacity(1.)) + .child(div().w(px(10.)).h(px(16.)).bg(linear_gradient( + 90., + linear_color_stop(fade, 0.), + linear_color_stop(backing, 1.), + ))) + .child(buttons) + .into_any_element(), + ) + } + /// What the sidebar row hid: the full title, the full branch and diff /// counts, the working directory, and the remote host the avatar only /// dots. `None` when the row showed everything — a card would add noise, @@ -1683,139 +2009,423 @@ impl Tty7App { .then_some(info) } - /// Fold the sidebar group `key` names, or unfold it if it is already - /// shut. Persisted: a group folded away is a statement about a repo you - /// are done with for now, and it should still be shut tomorrow. - /// The custom group a tab being dragged is currently held over, if any. + /// Where a tab being dragged in the sidebar would land if let go now, + /// when that is somewhere other than a new place in its own group. /// - /// Answers `None` unless a tab is in the air, the pointer is inside a - /// custom group's block, and that is not the group the tab is already in - /// — a drag that would change nothing offers nothing, so it falls back - /// to plain reordering. + /// Over a pinned group it did not come from, into that group; anywhere + /// below the divider, back to auto grouping — but only for a tab that is + /// in a pinned group, since one that is not already is auto grouped and + /// a drag that would change nothing offers nothing, falling back to plain + /// reordering. /// - /// Only custom groups are candidates. A repo group's membership is - /// decided by its tabs' cwds, so "put this tab in tty7" is a request the - /// sidebar has no honest way to honour; those blocks are never recorded, - /// so the pointer finds nothing over them. The same goes for Scratch, - /// which is where tabs land when no group claims them. - fn sidebar_regroup_target(&self, window: &Window) -> Option { + /// No auto group is a target. Its membership is decided by its tabs' + /// cwds, so "put this tab in tty7" is a request the sidebar has no honest + /// way to honour; the only thing a drop below the divider can mean is + /// "stop keeping this tab by hand". + fn sidebar_regroup_target(&self, window: &Window) -> Option { let dragged = crate::ui::reorder::dragged_sidebar_tab(&self.reorder)?; let here = self .tabs .iter() .find(|t| t.tree_id.get() == dragged) - .and_then(|t| t.sidebar_group.borrow().clone()); + .and_then(|t| t.group.get()) + .filter(|g| self.sidebar_groups.contains(*g)); let pointer = window.mouse_position(); - self.sidebar_group_slots + if let Some((id, _)) = self + .sidebar_group_slots .borrow() .iter() .find(|(_, bounds)| bounds.contains(&pointer)) - .map(|(key, _)| key.clone()) - .filter(|key| Some(key) != here.as_ref()) - } - - /// The custom groups that exist right now, in sidebar order. - /// - /// A group exists only while a tab says it does — there is no list of - /// groups anywhere else. That is what makes "move the last tab out" the - /// same gesture as "delete the group", and it is the same rule a repo - /// group already lives by. - /// - /// Read off the tabs rather than off [`Self::sidebar_group_keys`] so - /// that building a menu never runs the cwd probe, which writes back. - pub(crate) fn custom_group_names(&self) -> Vec { - let mut out: Vec = Vec::new(); - for tab in &self.tabs { - if let Some(GroupKey::Custom(name)) = tab.sidebar_group.borrow().as_ref() - && !out.iter().any(|n| n == name) - { - out.push(name.clone()); - } + { + return (Some(*id) != here).then_some(reorder::Regroup::Into(*id)); } - out + let below = self + .sidebar_divider + .get() + .is_some_and(|d| pointer.y >= d.origin.y); + (below && here.is_some()).then_some(reorder::Regroup::ToAuto) } - /// Put the dragged tab in the group it was dropped on. + /// The pinned groups there are, in sidebar order, with the name each + /// header reads — for menus that offer them. + pub(crate) fn pinned_group_names(&self) -> Vec<(GroupId, String)> { + let names = pinned_names(&self.sidebar_groups.pinned); + self.sidebar_groups + .pinned + .iter() + .map(|g| g.id) + .zip(names) + .collect() + } + + /// Change this workspace's groups, and send the change to the machine so + /// every other window onto the workspace draws it too. + pub(crate) fn edit_groups( + &mut self, + cx: &mut Context, + edit: impl FnOnce(&mut WorkspaceGroups), + ) { + edit(&mut self.sidebar_groups); + // Up as one `WorkspaceSetGroups`, queued ahead of any `TabSetGroup` + // the same edit made — which the save below sends. + crate::ui::tree_sync::push_groups(cx, self.workspace, self.sidebar_groups.clone()); + self.save_session(cx); + cx.notify(); + } + + /// Take the workspace's groups as another window, or the machine, has them. + /// + /// Every tab's [`EntryWatch`](crate::core::group_key::EntryWatch) starts + /// over from where it is. A folder that arrives from elsewhere is not one + /// this window's tabs walked into — and in particular, a window that drew + /// a frame before its copy of the groups landed saw no folders at all, so + /// without this every tab sitting in a pinned folder at launch would read + /// the groups landing as an entry, and a tab dragged out of its folder + /// group would be pulled back in on every start. The window that pinned + /// the folder gathers the tabs itself and says so tab by tab. + pub(crate) fn adopt_sidebar_groups(&mut self, groups: WorkspaceGroups, cx: &mut Context) { + if self.sidebar_groups == groups { + return; + } + self.sidebar_groups = groups; + for tab in &self.tabs { + tab.folder_watch + .set(crate::core::group_key::EntryWatch::baseline()); + } + cx.notify(); + } + + /// Put the dragged tab where it was dropped. /// /// By id rather than index: a drag is several frames long, and a tab /// closing anywhere else in that time would shift every index after it. pub(crate) fn regroup_tab( &mut self, tab: tty7_core::core::machine::TabId, - key: GroupKey, + target: reorder::Regroup, cx: &mut Context, ) { let Some(index) = self.tabs.iter().position(|t| t.tree_id.get() == tab) else { return; }; - self.set_tab_group(index, Some(key), cx); + let group = match target { + reorder::Regroup::Into(id) => Some(id), + reorder::Regroup::ToAuto => None, + }; + self.set_tab_group(index, group, cx); } - /// Put tab `index` in `key`, or hand it back to the cwd probe when `key` - /// is `None`. - /// - /// Clearing is the only way back. A stated group locks the probe out of - /// that tab for good, so without a way to clear it a tab moved into a - /// custom group could never follow its cwd again — and the user has no - /// way to tell that is what they just did. + /// Put tab `index` in pinned group `group`, or hand it back to auto + /// grouping when `group` is `None`. pub(crate) fn set_tab_group( &mut self, index: usize, - key: Option, + group: Option, cx: &mut Context, ) { let Some(tab) = self.tabs.get(index) else { return; }; - *tab.sidebar_group.borrow_mut() = key; + tab.group.set(group); // Carries the move to the daemon as a `TabSetGroup`, so another // window on the same workspace sees it too. self.save_session(cx); cx.notify(); } - /// Make a new custom group, put tab `index` in it, and open its header - /// for renaming. + /// A name no pinned group is using yet, for a group about to be made. + /// + /// The placeholder only has to be unique — the rename box opens on it + /// selected, so the first keystroke replaces it. Unique still matters: + /// two headers reading the same name are two groups nobody can tell + /// apart. + fn fresh_group_name(&self) -> String { + let taken: Vec = self + .pinned_group_names() + .into_iter() + .map(|(_, n)| n) + .collect(); + let base = t(L10nKey::SidebarNewGroupName).to_string(); + (1..) + .map(|n| match n { + 1 => base.clone(), + n => format!("{base} {n}"), + }) + .find(|candidate| !taken.contains(candidate)) + .expect("an unbounded range always reaches an untaken name") + } + + /// Make a new label group, put tab `index` in it, and open its header for + /// renaming. /// /// No dialog: the tab is in the group before a character is typed, so - /// what the name is being given to is on screen while it is chosen. The - /// placeholder only has to be unique — the box opens selected, so the - /// first keystroke replaces it. + /// what the name is being given to is on screen while it is chosen. pub(crate) fn new_tab_group( &mut self, index: usize, window: &mut Window, cx: &mut Context, ) { - let taken = self.custom_group_names(); - let base = t(L10nKey::SidebarNewGroupName).to_string(); - let name = (1..) - .map(|n| match n { - 1 => base.clone(), - n => format!("{base} {n}"), - }) - .find(|candidate| !taken.contains(candidate)) - .expect("an unbounded range always reaches an untaken name"); - let Some(key) = GroupKey::custom(&name) else { - return; - }; - self.set_tab_group(index, Some(key.clone()), cx); - self.start_group_rename(key, window, cx); + let group = PinnedGroup::label(self.fresh_group_name()); + let id = group.id; + if let Some(tab) = self.tabs.get(index) { + tab.group.set(Some(id)); + } + self.edit_groups(cx, |groups| groups.pinned.push(group)); + self.start_group_rename(id, window, cx); } - /// Open the header of custom group `key` for renaming. - pub(crate) fn start_group_rename( + /// Make a new, empty label group and open its header for renaming — the + /// palette's "New Group". It stays on screen empty until a tab is dragged + /// into it or opened from its header. + pub(crate) fn new_empty_group(&mut self, window: &mut Window, cx: &mut Context) { + let group = PinnedGroup::label(self.fresh_group_name()); + let id = group.id; + self.edit_groups(cx, |groups| groups.pinned.push(group)); + self.start_group_rename(id, window, cx); + } + + /// Keep the auto group `key`: pin it, with the tabs in it now. + /// + /// A repo group becomes a folder group on the repo's home, so tabs that + /// walk into the repo later join it too, and it reads the repo's name + /// until renamed. A host group has no folder on this workspace's machine + /// to keep, so it becomes a label group named after the host. + /// + /// The fold comes along: a group that was shut stays shut, rather than + /// springing open in its new place. + pub(crate) fn pin_auto_group(&mut self, key: AutoKey, cx: &mut Context) { + let mut group = match &key { + AutoKey::Repo(root) => PinnedGroup::folder(root), + AutoKey::SshHost(host) => PinnedGroup::label(host.clone()), + }; + group.collapsed = self.sidebar_groups.auto_collapsed.contains(&key); + let id = group.id; + let wanted = Some(GroupKey::Auto(key.clone())); + for (tab, place) in self.tabs.iter().zip(self.sidebar_group_keys(cx)) { + if place == wanted { + tab.group.set(Some(id)); + } + } + self.edit_groups(cx, |groups| { + groups.auto_collapsed.retain(|k| *k != key); + groups.pinned.push(group); + }); + } + + /// Pin `folder` as a group of its own — a folder dropped from Finder, or + /// "Pin as Group" in the file tree. Pinning one that is already pinned + /// does nothing: two groups keeping one folder would split its tabs + /// between them by nothing but list order. + /// + /// Tabs already sitting in the folder are gathered in on the next frame, + /// the way a tab walking in would be: the folder is new, so each of them + /// has just "entered" it as far as [`EntryWatch`](crate::core::group_key::EntryWatch) can tell. + pub(crate) fn pin_folder(&mut self, folder: PathBuf, cx: &mut Context) -> GroupId { + let spelled = folder.to_string_lossy(); + if let Some(kept) = self + .sidebar_groups + .pinned + .iter() + .find(|g| g.folder.as_deref() == Some(&*spelled)) + { + return kept.id; + } + let group = PinnedGroup::folder(&folder); + let id = group.id; + self.edit_groups(cx, |groups| groups.pinned.push(group)); + id + } + + /// The palette's "Open Folder as Group…": pick a folder on this computer, + /// pin it, and open a tab in it — "open" is the promise, and an empty group + /// with nothing in it would not keep it. + pub(crate) fn open_folder_as_group(&mut self, cx: &mut Context) { + let rx = cx.prompt_for_paths(gpui::PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: None, + }); + cx.spawn(async move |this, cx| { + if let Ok(Ok(Some(paths))) = rx.await + && let Some(path) = paths.into_iter().next() + { + let _ = this.update_in(cx, |this, window, cx| { + let id = this.pin_folder(path, cx); + this.new_tab_in_group(GroupKey::Pinned(id), window, cx); + }); + } + }) + .detach(); + } + + /// Folders dropped on the sidebar from Finder or Explorer, pinned as + /// groups. Anything that is not a directory is let fall: a file names no + /// group, and guessing its parent would pin something nobody pointed at. + /// + /// Only on a workspace on this computer. A dropped path is this + /// machine's, and a folder group keeps a directory on the workspace's + /// host — on a remote one the same spelling names something else, or + /// nothing at all. + pub(crate) fn pin_dropped_folders(&mut self, paths: &[PathBuf], cx: &mut Context) { + if crate::core::session::WorkspaceStore::all(cx) + .get(self.workspace) + .is_some_and(|w| w.is_remote()) + { + return; + } + for path in paths.iter().filter(|p| p.is_dir()) { + self.pin_folder(path.clone(), cx); + } + } + + /// Delete pinned group `id`. Its tabs are not closed — they go back to + /// auto grouping, which is where a tab nobody filed by hand belongs. + /// + /// Unpinning a folder group is the same act: the group stops being kept, + /// and its tabs fall back to the auto groups their cwds resolve to. + pub(crate) fn delete_group(&mut self, id: GroupId, cx: &mut Context) { + for tab in &self.tabs { + if tab.group.get() == Some(id) { + tab.group.set(None); + } + } + if self.group_rename.as_ref().is_some_and(|r| r.group == id) { + self.group_rename = None; + } + self.edit_groups(cx, |groups| groups.pinned.retain(|g| g.id != id)); + } + + /// Point pinned group `id` at `folder`, or make it a label group when + /// `folder` is `None`. + /// + /// A folder group nobody renamed reads its folder's name, and would read + /// nothing once the folder is gone — so clearing the folder keeps the + /// name it was showing. + pub(crate) fn set_group_folder( + &mut self, + id: GroupId, + folder: Option, + cx: &mut Context, + ) { + let shown = self + .pinned_group_names() + .into_iter() + .find(|(g, _)| *g == id) + .map(|(_, n)| n); + self.edit_groups(cx, |groups| { + let Some(group) = groups.get_mut(id) else { + return; + }; + if folder.is_none() && group.given_name().is_none() { + group.name = shown; + } + group.folder = folder.map(|f| f.to_string_lossy().into_owned()); + }); + } + + /// Ask for a folder for pinned group `id` with the system picker. Only + /// for a workspace on this machine: the picker browses this computer, and + /// a path picked here means nothing to a remote one. + pub(crate) fn pick_group_folder(&mut self, id: GroupId, cx: &mut Context) { + let rx = cx.prompt_for_paths(gpui::PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: None, + }); + cx.spawn(async move |this, cx| { + if let Ok(Ok(Some(paths))) = rx.await + && let Some(path) = paths.into_iter().next() + { + let _ = this.update(cx, |this, cx| this.set_group_folder(id, Some(path), cx)); + } + }) + .detach(); + } + + /// The directory the active tab is working in, on the workspace's host — + /// what "Use Current Tab's Folder" pins a group to. + pub(crate) fn active_tab_folder(&self, window: &Window, cx: &gpui::App) -> Option { + let leaf = self + .tabs + .get(self.active)? + .pane + .focused_or_first(window, cx)?; + leaf.read(cx).effective_host_cwd() + } + + /// Open a tab in the group drawn under `key` — a header's "+" and its + /// menu's "New Tab". + /// + /// A folder group opens in its folder, a repo group in its repo; a label + /// group has no directory of its own and opens where ⌘T would. The tab + /// joins a pinned group outright rather than waiting for its cwd to walk + /// in: a label group has nothing to walk into, and "new tab here" should + /// not depend on a probe. + pub(crate) fn new_tab_in_group( &mut self, key: GroupKey, window: &mut Window, cx: &mut Context, ) { - let GroupKey::Custom(current) = &key else { - // A repo group is named after its root. There is nothing to - // rename that would not be a lie about where the tabs are. + let before = self.tabs.len(); + let folder = match &key { + GroupKey::Pinned(id) => self + .sidebar_groups + .get(*id) + .and_then(|g| g.folder_path().map(Path::to_path_buf)), + GroupKey::Auto(AutoKey::Repo(root)) => Some(root.clone()), + GroupKey::Auto(AutoKey::SshHost(_)) => None, + }; + match folder { + Some(folder) => self.new_tab_at(folder, window, cx), + None => self.new_tab(window, cx), + } + if self.tabs.len() == before { + return; + } + if let GroupKey::Pinned(id) = key + && let Some(tab) = self.tabs.get(self.active) + { + tab.group.set(Some(id)); + self.save_session(cx); + } + } + + /// Put the pinned groups in `order` — indices into the list as it stands. + pub(crate) fn apply_pinned_order(&mut self, order: &[usize], cx: &mut Context) { + let pinned = &self.sidebar_groups.pinned; + if order.len() != pinned.len() || order.iter().enumerate().all(|(i, &o)| i == o) { + return; + } + let reordered: Vec = order + .iter() + .filter_map(|&i| pinned.get(i).cloned()) + .collect(); + if reordered.len() != pinned.len() { + return; + } + self.edit_groups(cx, |groups| groups.pinned = reordered); + } + + /// Open the header of pinned group `id` for renaming. + pub(crate) fn start_group_rename( + &mut self, + id: GroupId, + window: &mut Window, + cx: &mut Context, + ) { + let Some(current) = self + .pinned_group_names() + .into_iter() + .find(|(g, _)| *g == id) + .map(|(_, n)| n) + else { return; }; - let input = Self::rename_box(current.clone(), window, cx); + let input = Self::rename_box(current, window, cx); let subs = vec![cx.subscribe_in( &input, window, @@ -1827,108 +2437,151 @@ impl Tty7App { }, )]; self.group_rename = Some(crate::ui::app::GroupRename { - key, + group: id, input, _subs: subs, }); cx.notify(); } - /// Write the typed name onto every tab that claims the old one. - /// - /// All in one pass. A group is only the set of tabs that name it, so a - /// rename that reached half of them would not be a half-renamed group — - /// it would be two groups. + /// Write the typed name onto the group being renamed. pub(crate) fn commit_group_rename(&mut self, window: &mut Window, cx: &mut Context) { let Some(rename) = self.group_rename.take() else { return; }; let value = rename.input.read(cx).value().trim().to_string(); - // A blank name is not a group. Taking it would drop every tab in it - // into Scratch, which is a lot to happen because a box was cleared - // and dismissed — so an empty name means "keep the name you had". - if let Some(new_key) = GroupKey::custom(&value) - && new_key != rename.key - { - for tab in &self.tabs { - let mut group = tab.sidebar_group.borrow_mut(); - if group.as_ref() == Some(&rename.key) { - *group = Some(new_key.clone()); - } + self.edit_groups(cx, |groups| { + let Some(group) = groups.get_mut(rename.group) else { + return; + }; + match (value.is_empty(), group.folder.is_some()) { + // A folder group handed a blank name goes back to reading + // its folder's — which is how a rename is undone. + (true, true) => group.name = None, + // A label group has nothing to fall back on: a blank name + // would draw an unlabelled header, so it keeps the one it had. + (true, false) => {} + (false, _) => group.name = Some(value), } - // The fold list is keyed by name too, so a shut group that is - // renamed would spring open under a key nothing folds any more. - let (was, now) = ( - collapse_key(Some(&rename.key)), - collapse_key(Some(&new_key)), - ); - self.update_config(cx, |cfg| { - if let Some(at) = cfg.sidebar_collapsed_groups.iter().position(|p| *p == was) { - cfg.sidebar_collapsed_groups[at] = now.clone(); - } - }); - self.save_session(cx); - } + }); self.focus_active(window, cx); cx.notify(); } + /// Fold the group drawn under `key` (`None`: Ungrouped), or unfold it if + /// it is already shut. Stored with the workspace: a group folded away is a + /// statement about work you are done with for now, and every window onto + /// the workspace — and the next launch — should still show it shut. pub(crate) fn toggle_sidebar_group(&mut self, key: Option<&GroupKey>, cx: &mut Context) { - let id = collapse_key(key); - self.update_config(cx, |cfg| { - match cfg.sidebar_collapsed_groups.iter().position(|p| *p == id) { - Some(at) => { - cfg.sidebar_collapsed_groups.remove(at); - } - None => cfg.sidebar_collapsed_groups.push(id), - } - }); + self.edit_groups(cx, |groups| groups.toggle_folded(key)); } - fn sidebar_group_keys(&self, cx: &gpui::App) -> Vec> { - let grouping = cx.global::().sidebar_grouping; + /// Rule 2: a tab whose cwd walks into a pinned folder joins that group. + /// + /// Run once per frame, before anything asks where a tab is drawn. Every + /// tab's [`EntryWatch`](crate::core::group_key::EntryWatch) is fed whether or not the tab is in a pinned group + /// — that is what lets a tab dragged out of a folder group while it is + /// still inside the folder stay out: the watch already knows it is + /// inside, so staying there is no entry. Only a tab not already kept in a + /// pinned group acts on an entry; a kept tab never leaves on its own. + /// + /// A tab is only looked at once its repo probe has answered. Before that + /// the question "is it inside" has half an answer — the cwd without the + /// repo home a worktree is matched by — and recording that half as where + /// the tab *was* would read the other half landing a frame later as an + /// entry, pulling every restored worktree tab into its group at launch. + /// + /// SSH panes are never looked at: their cwd is on another machine, and a + /// folder pinned in this workspace names a directory on this one. + /// + /// Also where a tab's auto-group hint is refreshed from the live answer + /// and, when that answer moved, sent up with the tab — so the next launch + /// draws the tab where it last was instead of in Ungrouped. + pub(crate) fn settle_sidebar_groups(&mut self, cx: &mut Context) { + let mut changed = false; + for tab in &self.tabs { + let before = tab.auto_group.borrow().clone(); + changed |= self.tab_auto_group(tab, cx) != before; + } + let Some(cache) = cx.try_global::() else { + if changed { + self.save_session(cx); + } + return; + }; + let mut joined = changed; + for tab in &self.tabs { + let Some(leaf) = tab.pane.first_leaf() else { + continue; + }; + let Some(view) = leaf.terminal() else { + continue; + }; + let view = view.read(cx); + if view.remote_context().is_some() { + continue; + } + let Some(cwd) = view.git_status_cwd() else { + continue; + }; + let Some(home) = cache.known_repo_for(view.host_id(), cwd) else { + continue; + }; + let inside = self.sidebar_groups.folder_for(Some(cwd), home.as_deref()); + let mut watch = tab.folder_watch.get(); + let entered = watch.observe(inside); + tab.folder_watch.set(watch); + let kept = tab + .group + .get() + .is_some_and(|g| self.sidebar_groups.contains(g)); + if let Some(id) = entered + && !kept + { + tab.group.set(Some(id)); + joined = true; + } + } + if joined { + self.save_session(cx); + } + } + + /// The auto group tab `tab` resolves to (rule 3): the host for an SSH + /// pane, otherwise the repo its cwd is in, otherwise none (Ungrouped). + /// + /// Remembered on the tab, and the memory is what answers while there is + /// nothing to go on — a probe in flight, or a pane (a WSL one, say) whose + /// paths no probe reaches — so a tab does not bounce through Ungrouped + /// between one answer and the next. + fn tab_auto_group(&self, tab: &Tab, cx: &gpui::App) -> Option { + let resolved = tab.pane.first_leaf().and_then(|leaf| { + let view = leaf.terminal()?.read(cx); + let host = ssh_host(view.remote_context().as_ref()); + let known = view.git_status_cwd().and_then(|cwd| { + cx.try_global::()? + .known_repo_for(view.host_id(), cwd) + }); + auto_key(host.as_deref(), known) + }); + if let Some(key) = resolved { + *tab.auto_group.borrow_mut() = key; + } + tab.auto_group.borrow().clone() + } + + /// Where each tab is drawn, in `self.tabs` order: its pinned group, its + /// auto group, or `None` for Ungrouped (or for the flat list below the + /// pinned groups, with auto grouping off). + pub(crate) fn sidebar_group_keys(&self, cx: &gpui::App) -> Vec> { + let auto_grouping = cx.global::().sidebar_auto_grouping; self.tabs .iter() .map(|tab| { - // "No grouping" means no headers, full stop. A custom group - // says where a tab goes, not that a box may be drawn when the - // user asked for none — and letting one through would leave - // the sidebar showing that group beside a "Scratch" holding - // everything else, which is two headers more than the setting - // asked for. The key stays on the tab, so turning grouping - // back on brings it straight back. - if grouping == SidebarGrouping::None { - return None; - } - // A stated group outranks anything the cwd says. Without this - // the repo probe would drag a hand-placed tab back home on - // the very next frame, and no amount of clicking would keep - // it where it was put. - let stated = tab.sidebar_group.borrow().clone(); - if stated.as_ref().is_some_and(GroupKey::is_custom) { - return stated; - } - let resolved = tab.pane.first_leaf().and_then(|leaf| { - let view = leaf.terminal()?.read(cx); - match view.git_status_cwd() { - Some(cwd) => { - let known = cx - .global::() - .known_repo_for(view.host_id(), cwd); - resolved_group(grouping, known, cwd) - } - // A native SSH pane never gets a `git_status_cwd`: - // its paths are on a machine no `Host` reaches, so - // nothing probes them (#891). Its shell still says - // where it is, though, and that is enough for the - // folder fallback. - None => unprobed_group(grouping, &view.native_ssh_cwd()?), - } - }); - if let Some(group) = resolved { - *tab.sidebar_group.borrow_mut() = group; - } - tab.sidebar_group.borrow().clone() + // Resolved even with auto grouping off, so the remembered + // answer is warm the moment it is turned back on. + let auto = self.tab_auto_group(tab, cx); + place(tab.group.get(), &self.sidebar_groups, auto_grouping, auto) }) .collect() } @@ -1938,7 +2591,7 @@ impl Tty7App { return (0..self.tabs.len()).collect(); } let keys = self.sidebar_group_keys(cx); - sidebar_sections(&keys) + sidebar_sections(&keys, &self.sidebar_groups) .into_iter() .flat_map(|s| s.tabs) .collect() @@ -1955,81 +2608,199 @@ impl Tty7App { } } - /// Which group a tab about to be spawned in `cwd` belongs to, when the - /// repo probe for that directory has already landed. A bare `None` means - /// the cache never looked; `Some` is the group [`resolved_group`] reached - /// — the repo home, the cwd itself under repo-or-directory grouping, or - /// `Some(None)` for Scratch. + /// Where a tab about to be spawned in `cwd` goes. /// - /// A tab's group otherwise starts empty and only fills in once its shell - /// has started and reported a cwd, which parks every new tab in the - /// scratch group at the bottom of the sidebar until then. A tab spawned - /// from one already sitting in a repo inherits a warm cache, so seeding - /// it here lands the tab in its group on the first frame. - pub(crate) fn spawn_group( - &self, - cwd: Option<&Path>, - cx: &gpui::App, - ) -> Option> { - // A tab spawned from one sitting in a custom group joins it, and does - // so before the cwd is consulted at all — the cwd says nothing about - // a group the user stated by hand. - // - // Every caller here spawns from the active tab (the two that start - // from a named tab activate it first), so that is the one to inherit - // from. Without this, ⌘T inside a folded custom group would draw - // nothing but the header's count going up by one: the new tab would - // land in a repo group somewhere else, and the fold would hide it. - if let Some(stated) = self + /// A tab spawned from one kept in a pinned group joins it, before the cwd + /// is consulted at all — the cwd says nothing about a group the user + /// stated by hand. Every caller here spawns from the active tab (the ones + /// that start from a named tab activate it first), so that is the one to + /// inherit from. Without this, ⌘T inside a folded group would draw + /// nothing but the header's count going up by one. + /// + /// Otherwise a cwd inside a pinned folder joins that folder's group, and + /// the auto group is seeded from the repo cache when its probe for that + /// directory has already landed. A tab's auto group otherwise starts + /// empty and only fills in once its shell has reported a cwd, which parks + /// every new tab in Ungrouped at the bottom of the sidebar until then; a + /// tab spawned from one already in a repo inherits a warm cache, so this + /// lands it in its group on the first frame. + pub(crate) fn spawn_group(&self, cwd: Option<&Path>, cx: &gpui::App) -> SpawnPlace { + let kept = self .tabs .get(self.active) - .and_then(|t| t.sidebar_group.borrow().clone()) - .filter(GroupKey::is_custom) - { - return Some(Some(stated)); + .and_then(|t| t.group.get()) + .filter(|g| self.sidebar_groups.contains(*g)); + let known = cwd.and_then(|cwd| { + let host = self + .window_workspace(cx) + .as_ref() + .map_or(crate::ui::host_ops::HostId::LOCAL, |ws| ws.target.host_id()); + cx.try_global::()?.known_repo_for(host, cwd) + }); + let entered = cwd.and_then(|cwd| { + let home = known.clone().flatten(); + self.sidebar_groups.folder_for(Some(cwd), home.as_deref()) + }); + SpawnPlace { + group: kept.or(entered), + auto: auto_key(None, known), } - let cwd = cwd?; - let host = self - .window_workspace(cx) - .as_ref() - .map_or(crate::ui::host_ops::HostId::LOCAL, |ws| ws.target.host_id()); - let known = cx.try_global::()?.known_repo_for(host, cwd); - resolved_group(cx.global::().sidebar_grouping, known, cwd) } } -/// The group a probed cwd resolves to under `grouping`: the repo home when -/// the cache found one, otherwise Scratch — or the cwd itself under -/// repo-or-directory grouping, so a shell in a plain folder still gets a -/// header. `known` is the cache's three-valued answer; a probe that never -/// ran resolves to `None`, no decision, and the tab keeps whatever group it -/// already has rather than bouncing through Scratch mid-probe. -fn resolved_group( - grouping: SidebarGrouping, - known: Option>, - cwd: &Path, -) -> Option> { - Some(match known? { - Some(root) => Some(GroupKey::Repo(root)), - None if grouping == SidebarGrouping::RepoOrDirectory => { - Some(GroupKey::Repo(cwd.to_path_buf())) - } - None => None, +/// A header that can be picked up, and which of the two orders it belongs to. +#[derive(Clone, Copy)] +enum HeaderSlot { + /// Slot `n` among the pinned headers, above the divider. + Pinned(usize), + /// Slot `n` among the auto headers, below it. + Auto(usize), +} + +/// One item of a group header's menu, acting on the app when it is chosen. +fn menu_item( + label: L10nKey, + app: &gpui::WeakEntity, + act: impl Fn(&mut Tty7App, &mut Window, &mut Context) + 'static, +) -> PopupMenuItem { + let app = app.clone(); + PopupMenuItem::new(t(label)).on_click(move |_, window, cx| { + let _ = app.update(cx, |this, cx| act(this, window, cx)); }) } -/// The group for a cwd no repo probe can ever run in — a native SSH pane's. -/// "Never probed" would leave such a tab in Scratch for good, so it is read -/// as a settled "no repo": repo-or-directory grouping files it under the -/// folder, the same as a local shell in a plain directory, and repo grouping -/// leaves it in Scratch. A remote repo therefore groups by the folder the -/// shell is in, not its root — there is nothing to ask for the root. +/// A pinned group's header menu. Everything about a kept group is the +/// user's to change: its name, the folder it keeps, and whether it is kept at +/// all. Deleting closes nothing — its tabs go back to auto grouping. /// -/// The key is the bare path, the same as every other derived group: a remote -/// `/home/ubuntu` and a local one share a header, as two remote workspaces' -/// identical paths already would. -fn unprobed_group(grouping: SidebarGrouping, cwd: &Path) -> Option> { - resolved_group(grouping, Some(None), cwd) +/// "Set Folder…" asks the system picker, which browses this computer, so it +/// is only offered for a workspace on it; "Use Current Tab's Folder" works +/// anywhere, since the tab's cwd is on the workspace's own host. +fn pinned_header_menu( + menu: gpui_component::menu::PopupMenu, + id: GroupId, + has_folder: bool, + local: bool, + app: &gpui::WeakEntity, + window: &Window, + cx: &gpui::App, +) -> gpui_component::menu::PopupMenu { + let current = app + .upgrade() + .and_then(|a| a.read(cx).active_tab_folder(window, cx)); + let mut menu = menu.min_w(px(200.)).item(menu_item( + L10nKey::SidebarRenameGroup, + app, + move |this, w, cx| this.start_group_rename(id, w, cx), + )); + if local { + menu = menu.item(menu_item( + L10nKey::SidebarSetGroupFolder, + app, + move |this, _, cx| this.pick_group_folder(id, cx), + )); + } + menu = menu.item( + menu_item( + L10nKey::SidebarUseCurrentTabFolder, + app, + move |this, w, cx| { + if let Some(folder) = this.active_tab_folder(w, cx) { + this.set_group_folder(id, Some(folder), cx); + } + }, + ) + .disabled(current.is_none()), + ); + if has_folder { + menu = menu.item(menu_item( + L10nKey::SidebarClearGroupFolder, + app, + move |this, _, cx| this.set_group_folder(id, None, cx), + )); + } + menu = menu + .separator() + .item(menu_item( + L10nKey::SidebarGroupNewTab, + app, + move |this, w, cx| this.new_tab_in_group(GroupKey::Pinned(id), w, cx), + )) + .separator(); + if has_folder { + menu = menu.item(menu_item( + L10nKey::SidebarUnpinGroup, + app, + move |this, _, cx| this.delete_group(id, cx), + )); + } + menu.item(menu_item( + L10nKey::SidebarDeleteGroup, + app, + move |this, _, cx| this.delete_group(id, cx), + )) +} + +/// An auto group's header menu: pin it, or open a tab in it. Nothing else — +/// an auto group is derived, so there is nothing about it to edit until it +/// is pinned, and pinning is always an explicit act. +fn auto_header_menu( + menu: gpui_component::menu::PopupMenu, + key: AutoKey, + app: &gpui::WeakEntity, +) -> gpui_component::menu::PopupMenu { + let pin = key.clone(); + let mut menu = menu.min_w(px(200.)).item(menu_item( + L10nKey::SidebarPinGroup, + app, + move |this, _, cx| this.pin_auto_group(pin.clone(), cx), + )); + // A host group has no directory to open a tab in, and no way to open a + // shell on the host that would not guess at how the others got there. + if let AutoKey::Repo(_) = &key { + menu = menu.item(menu_item( + L10nKey::SidebarGroupNewTab, + app, + move |this, w, cx| this.new_tab_in_group(GroupKey::Auto(key.clone()), w, cx), + )); + } + menu +} + +/// Where a tab about to be spawned goes, worked out before it exists — see +/// [`Tty7App::spawn_group`]. +pub(crate) struct SpawnPlace { + group: Option, + auto: Option>, +} + +impl SpawnPlace { + /// Put `tab` where this says. + pub(crate) fn seat(&self, tab: &Tab) { + if let Some(id) = self.group { + tab.group.set(Some(id)); + } + if let Some(auto) = &self.auto { + *tab.auto_group.borrow_mut() = auto.clone(); + } + } +} + +/// The host an SSH pane is on, when it is one. +/// +/// A native SSH pane and a shell that has ssh'd onward from a local prompt +/// are grouped alike, by the target they name: in neither case does a `Host` +/// of ours reach the far side, so neither has a repo to probe — the first +/// reports a remote cwd that names nothing here, the second only the local +/// directory the `ssh` was typed in, which says nothing about where its shell +/// now is. The host is the one thing about either that is certain. A WSL pane +/// is not an SSH one: its distro is on this machine, and it keeps the group it +/// had. +fn ssh_host(remote: Option<&crate::daemon::protocol::RemoteContext>) -> Option { + use crate::daemon::protocol::RemoteKind; + remote + .filter(|r| matches!(r.kind, RemoteKind::Ssh | RemoteKind::NativeSsh)) + .map(|r| r.target.clone()) } #[derive(Debug, PartialEq)] @@ -2039,38 +2810,70 @@ struct Section { tabs: Vec, } -fn sidebar_sections(keys: &[Option]) -> Vec
{ - let mut group_order: Vec<&GroupKey> = Vec::new(); - for k in keys.iter().flatten() { - if !group_order.contains(&k) { - group_order.push(k); +impl Section { + fn pinned(&self) -> Option { + self.key.as_ref().and_then(GroupKey::pinned) + } +} + +/// The sidebar's sections, top to bottom: every pinned group in the user's +/// order — an empty one too, since a kept group stays until it is deleted — +/// then the auto groups in the order their first tab appears, then +/// Ungrouped. +/// +/// Ungrouped only draws a header beside other groups below the divider. With +/// no auto group to set it apart from — grouping off, or nothing resolved +/// yet — it is just the list, and a header over the whole of it would be a +/// label on nothing. The same goes for a sidebar with no groups at all, which +/// comes out as one headerless section holding every tab. +fn sidebar_sections(keys: &[Option], groups: &WorkspaceGroups) -> Vec
{ + let members = |key: &GroupKey| -> Vec { + (0..keys.len()) + .filter(|&i| keys[i].as_ref() == Some(key)) + .collect() + }; + let mut auto_order: Vec<&AutoKey> = Vec::new(); + for k in keys.iter().flatten().filter_map(GroupKey::auto) { + if !auto_order.contains(&k) { + auto_order.push(k); } } - if group_order.is_empty() { + let rest: Vec = (0..keys.len()).filter(|&i| keys[i].is_none()).collect(); + if groups.pinned.is_empty() && auto_order.is_empty() { return vec![Section { key: None, name: None, - tabs: (0..keys.len()).collect(), + tabs: rest, }]; } - let names = section_names(&group_order); - let mut sections: Vec
= group_order + let pinned = pinned_names(&groups.pinned); + let auto = auto_names(&auto_order); + let mut sections: Vec
= groups + .pinned .iter() - .zip(names) - .map(|(key, name)| Section { - key: Some((*key).clone()), - name: Some(name), - tabs: (0..keys.len()) - .filter(|&i| keys[i].as_ref() == Some(*key)) - .collect(), + .zip(pinned) + .map(|(g, name)| { + let key = GroupKey::Pinned(g.id); + Section { + tabs: members(&key), + key: Some(key), + name: Some(name), + } }) .collect(); - let scratch: Vec = (0..keys.len()).filter(|&i| keys[i].is_none()).collect(); - if !scratch.is_empty() { + sections.extend(auto_order.iter().zip(auto).map(|(k, name)| { + let key = GroupKey::Auto((*k).clone()); + Section { + tabs: members(&key), + key: Some(key), + name: Some(name), + } + })); + if !rest.is_empty() { sections.push(Section { key: None, - name: Some(t(L10nKey::SidebarScratchGroup).to_string()), - tabs: scratch, + name: (!auto_order.is_empty()).then(|| t(L10nKey::SidebarUngroupedGroup).to_string()), + tabs: rest, }); } sections @@ -2078,6 +2881,7 @@ fn sidebar_sections(keys: &[Option]) -> Vec
{ fn reordered_rows( keys: &[Option], + groups: &WorkspaceGroups, group: &Option, visible: &[usize], from: usize, @@ -2093,7 +2897,7 @@ fn reordered_rows( members.insert(at, moved); let mut out: Vec = Vec::with_capacity(keys.len()); - for g in sidebar_sections(keys).iter().map(|s| &s.key) { + for g in sidebar_sections(keys, groups).iter().map(|s| &s.key) { if g == group { out.extend_from_slice(&members); } else { @@ -2103,16 +2907,17 @@ fn reordered_rows( Some(out) } -fn regrouped_order( - keys: &[Option], - from: &GroupKey, - to: &GroupKey, -) -> Option> { +/// The tab order that moves auto group `from` to where auto group `to` is. +/// +/// Auto groups are ordered by where their first tab sits, so moving one is +/// moving its tabs. Pinned groups keep an order of their own and take no part: +/// their tabs follow the auto groups' here, where they draw no differently. +fn regrouped_order(keys: &[Option], from: &AutoKey, to: &AutoKey) -> Option> { if from == to { return None; } - let mut order: Vec<&GroupKey> = Vec::new(); - for k in keys.iter().flatten() { + let mut order: Vec<&AutoKey> = Vec::new(); + for k in keys.iter().flatten().filter_map(GroupKey::auto) { if !order.contains(&k) { order.push(k); } @@ -2122,38 +2927,80 @@ fn regrouped_order( let moved = order.remove(fi); order.insert(ti, moved); + let auto_of = |i: usize| keys[i].as_ref().and_then(GroupKey::auto); 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| auto_of(i) == Some(*g))); } - out.extend((0..keys.len()).filter(|&i| keys[i].is_none())); + out.extend((0..keys.len()).filter(|&i| auto_of(i).is_none())); Some(out) } -/// What each section's header reads, in `keys` order. +/// The order of all pinned groups after the header in visible slot `from` is +/// dropped on slot `to`, as indices into `all`. `shown` is the pinned groups +/// on screen, in order — a search can hide some, and those keep their places. +fn reordered_pinned( + all: &[GroupId], + shown: &[GroupId], + from: usize, + to: usize, +) -> Option> { + let (&moved, &anchor) = (shown.get(from)?, shown.get(to)?); + if moved == anchor { + return None; + } + let mut order: Vec = all.to_vec(); + order.retain(|g| *g != moved); + let at = order.iter().position(|g| *g == anchor)? + usize::from(to > from); + order.insert(at, moved); + order + .iter() + .map(|g| all.iter().position(|a| a == g)) + .collect() +} + +/// What each pinned group's header reads, in list order: the name the user +/// gave it, or its folder's last component — lengthened, like a repo's, when +/// two folders end the same way. A label group always has a given name. +fn pinned_names(pinned: &[PinnedGroup]) -> Vec { + let unnamed: Vec = pinned + .iter() + .filter(|g| g.given_name().is_none()) + .filter_map(|g| g.folder_path().map(Path::to_path_buf)) + .collect(); + let mut from_folders = group_names(&unnamed.iter().collect::>()).into_iter(); + pinned + .iter() + .map(|g| match (g.given_name(), g.folder_path()) { + (Some(name), _) => name.to_string(), + (None, Some(_)) => from_folders + .next() + .expect("group_names answers one name per folder"), + (None, None) => String::new(), + }) + .collect() +} + +/// What each auto group's header reads, in `keys` order. /// /// Only repo roots go through [`group_names`]. They are paths, so two of them /// can perfectly well end in the same component and need lengthening until -/// they differ. A custom group's name is the name the user typed — there is -/// nothing to shorten and nothing to disambiguate against, and running it -/// through the path splitter would chop a name containing a `/` into -/// components and then "disambiguate" it by growing a prefix that was never -/// there. -fn section_names(keys: &[&GroupKey]) -> Vec { +/// they differ. A host group reads its `user@host` target as it is. +fn auto_names(keys: &[&AutoKey]) -> Vec { let roots: Vec<&PathBuf> = keys .iter() .filter_map(|k| match k { - GroupKey::Repo(p) => Some(p), - GroupKey::Custom(_) => None, + AutoKey::Repo(p) => Some(p), + AutoKey::SshHost(_) => None, }) .collect(); let mut disambiguated = group_names(&roots).into_iter(); keys.iter() .map(|k| match k { - GroupKey::Repo(_) => disambiguated + AutoKey::Repo(_) => disambiguated .next() .expect("group_names answers one name per root"), - GroupKey::Custom(name) => name.clone(), + AutoKey::SshHost(host) => host.clone(), }) .collect() } @@ -2240,13 +3087,13 @@ mod fold_tests { app.sidebar_slots.borrow()[i].size.height > px(0.) } - /// Put tab `i` in a directory and tell the cache that directory is the - /// repo `root`, so the sidebar's own probe has a real answer to act on. - /// Both halves are needed: the probe reads the tab's cwd and looks it up - /// in the cache, and either one missing makes it return "no decision", - /// which would leave every group below untouched and every assertion - /// about overwriting vacuous. - fn plant_repo(app: &Tty7App, i: usize, cwd: &str, root: &str, cx: &mut gpui::App) { + /// Put tab `i` in a directory and tell the cache that directory is in the + /// repo `root`, whose home is `home` — the same as `root` unless the + /// checkout is a linked worktree. Both halves are needed: the sidebar + /// reads the tab's cwd and looks it up in the cache, and either one + /// missing makes it return "no decision", which would leave every group + /// below untouched and every assertion about moving vacuous. + fn plant(app: &Tty7App, i: usize, cwd: &str, root: &str, home: &str, cx: &mut gpui::App) { use crate::terminal::git_status::{GitStatusCache, RepoSnapshot}; use crate::ui::host_ops::HostId; @@ -2263,7 +3110,7 @@ mod fold_tests { &cwd, Some(RepoSnapshot { root: PathBuf::from(root), - home: PathBuf::from(root), + home: PathBuf::from(home), branch: "main".into(), counts: Some((0, 0)), }), @@ -2271,24 +3118,70 @@ mod fold_tests { }); } - /// The rule the whole feature rests on. A group the user stated by hand - /// is not the probe's to change: without this the cwd probe would drag a - /// hand-placed tab back into its repo on the very next frame, and no - /// amount of clicking would keep it where it was put. + fn plant_repo(app: &Tty7App, i: usize, cwd: &str, root: &str, cx: &mut gpui::App) { + plant(app, i, cwd, root, root, cx); + } + + /// Put tab `i` somewhere the cache knows is in no repo at all. + fn plant_plain(app: &Tty7App, i: usize, cwd: &str, cx: &mut gpui::App) { + use crate::terminal::git_status::GitStatusCache; + use crate::ui::host_ops::HostId; + + let cwd = PathBuf::from(cwd); + let leaf = app.tabs[i].pane.first_leaf().expect("test tab has a pane"); + leaf.terminal() + .expect("test pane is a terminal") + .update(cx, |view, _| { + view.set_git_status_cwd_for_test(Some(cwd.clone())) + }); + cx.update_global::(|cache, _| { + cache.finish_probe(HostId::LOCAL, &cwd, None); + }); + } + + fn repo(s: &str) -> Option { + Some(GroupKey::Auto(AutoKey::Repo(PathBuf::from(s)))) + } + + /// Pin a label group called `name`, answering its id. + fn label(app: &mut Tty7App, name: &str, cx: &mut Context) -> GroupId { + let group = PinnedGroup::label(name); + let id = group.id; + app.edit_groups(cx, |groups| groups.pinned.push(group)); + id + } + + /// Pin `folder`, answering the group's id. + fn folder(app: &mut Tty7App, folder: &str, cx: &mut Context) -> GroupId { + app.pin_folder(PathBuf::from(folder), cx); + app.sidebar_groups + .pinned + .iter() + .find(|g| g.folder.as_deref() == Some(folder)) + .expect("just pinned") + .id + } + + /// Rule 1. A tab kept in a pinned group is not the probe's to move: + /// without this the cwd would drag a hand-placed tab back into its repo + /// on the very next frame, and no amount of clicking would keep it where + /// it was put. /// - /// Tab 1 is the control. It carries no stated group, so the same probe - /// that must leave tab 0 alone has to move tab 1 — otherwise this test - /// would pass just as well with the probe switched off entirely. + /// Tab 1 is the control. It is kept nowhere, so the same probe that must + /// leave tab 0 alone has to file tab 1 — otherwise this test would pass + /// just as well with the probe switched off entirely. #[gpui::test] - fn a_probe_moves_a_derived_group_and_never_a_stated_one(cx: &mut TestAppContext) { + fn a_probe_files_an_auto_tab_and_never_a_pinned_one(cx: &mut TestAppContext) { let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); - app.update(&mut vcx, |app, cx| { - *app.tabs[0].sidebar_group.borrow_mut() = GroupKey::custom("work"); + let work = app.update(&mut vcx, |app, cx| { + let work = label(app, "work", cx); + app.set_tab_group(0, Some(work), cx); for i in 0..2 { plant_repo(app, i, "/w/probed/sub", "/w/probed", cx); } cx.notify(); + work }); vcx.run_until_parked(); @@ -2296,51 +3189,44 @@ mod fold_tests { let keys = app.sidebar_group_keys(cx); assert_eq!( keys[0], - GroupKey::custom("work"), - "the stated group survived a probe that had a real answer" + Some(GroupKey::Pinned(work)), + "the pinned group survived a probe that had a real answer" ); assert_eq!( keys[1], - Some(GroupKey::Repo(PathBuf::from("/w/probed"))), - "and that same probe did move the tab that only had a derived one" - ); - assert_eq!( - *app.tabs[0].sidebar_group.borrow(), - GroupKey::custom("work"), - "the tab itself was not written over either" + repo("/w/probed"), + "and that same probe did file the tab that was kept nowhere" ); }); } - /// The way out. A stated group locks the probe out of that tab, so if - /// clearing it did not hand the tab back, a tab moved into a custom - /// group could never follow its cwd again. + /// Dropping a tab below the divider is the way out of a pinned group: the + /// cwd decides again. #[gpui::test] - fn clearing_a_stated_group_hands_the_tab_back_to_the_probe(cx: &mut TestAppContext) { + fn a_tab_handed_back_to_auto_follows_its_cwd_again(cx: &mut TestAppContext) { let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); - app.update(&mut vcx, |app, cx| { + let (work, dragged) = app.update(&mut vcx, |app, cx| { plant_repo(app, 0, "/w/probed/sub", "/w/probed", cx); - app.set_tab_group(0, GroupKey::custom("work"), cx); + let work = label(app, "work", cx); + app.set_tab_group(0, Some(work), cx); + (work, app.tabs[0].tree_id.get()) + }); + vcx.run_until_parked(); + app.update(&mut vcx, |app, cx| { + assert_eq!(app.sidebar_group_keys(cx)[0], Some(GroupKey::Pinned(work))); + }); + + app.update(&mut vcx, |app, cx| { + app.regroup_tab(dragged, reorder::Regroup::ToAuto, cx) }); vcx.run_until_parked(); app.update(&mut vcx, |app, cx| { assert_eq!( app.sidebar_group_keys(cx)[0], - GroupKey::custom("work"), - "stated, so the probe is locked out" - ); - }); - - app.update(&mut vcx, |app, cx| app.set_tab_group(0, None, cx)); - vcx.run_until_parked(); - - app.update(&mut vcx, |app, cx| { - assert_eq!( - app.sidebar_group_keys(cx)[0], - Some(GroupKey::Repo(PathBuf::from("/w/probed"))), - "cleared, so the probe takes the tab back over" + repo("/w/probed"), + "dropped below the divider, so the probe takes the tab back" ); }); } @@ -2354,9 +3240,11 @@ mod fold_tests { let (app, mut vcx, _streams) = harness_with_tabs(cx, 3); let dragged = app.update(&mut vcx, |app, _| app.tabs[2].tree_id.get()); - app.update_in(&mut vcx, |app, window, cx| { + let work = app.update_in(&mut vcx, |app, window, cx| { + let work = label(app, "work", cx); app.close_tab(0, window, cx); - app.regroup_tab(dragged, GroupKey::custom("work").expect("non-blank"), cx); + app.regroup_tab(dragged, reorder::Regroup::Into(work), cx); + work }); vcx.run_until_parked(); @@ -2367,42 +3255,45 @@ mod fold_tests { .find(|t| t.tree_id.get() == dragged) .expect("the dragged tab is still open"); assert_eq!( - *moved.sidebar_group.borrow(), - GroupKey::custom("work"), + moved.group.get(), + Some(work), "the tab that was picked up is the tab that moved" ); assert!( app.tabs .iter() .filter(|t| t.tree_id.get() != dragged) - .all(|t| t.sidebar_group.borrow().is_none()), + .all(|t| t.group.get().is_none()), "and no bystander was regrouped in its place" ); }); } - /// ⌘T inside a custom group has to land in it. Otherwise the new tab + /// ⌘T inside a pinned group has to land in it. Otherwise the new tab /// goes wherever its cwd says, and if the group it was opened from is /// folded, the only thing that happens on screen is the header's count /// going up by one — the symptom #804 fixed for repo groups. #[gpui::test] - fn a_tab_spawned_inside_a_custom_group_joins_it(cx: &mut TestAppContext) { + fn a_tab_spawned_inside_a_pinned_group_joins_it(cx: &mut TestAppContext) { let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); - app.update(&mut vcx, |app, cx| { + let work = app.update(&mut vcx, |app, cx| { // A probe that would send the tab somewhere else if it were // consulted, so this cannot pass by there being no answer. plant_repo(app, 0, "/w/probed/sub", "/w/probed", cx); - app.set_tab_group(0, GroupKey::custom("work"), cx); + let work = label(app, "work", cx); + app.set_tab_group(0, Some(work), cx); app.active = 0; + work }); vcx.run_until_parked(); app.update(&mut vcx, |app, cx| { + let place = app.spawn_group(Some(&PathBuf::from("/w/probed/sub")), cx); assert_eq!( - app.spawn_group(Some(&PathBuf::from("/w/probed/sub")), cx), - Some(GroupKey::custom("work")), - "the stated group is inherited ahead of anything the cwd says" + place.group, + Some(work), + "the pinned group is inherited ahead of anything the cwd says" ); }); @@ -2410,159 +3301,387 @@ mod fold_tests { vcx.run_until_parked(); app.update(&mut vcx, |app, cx| { + let place = app.spawn_group(Some(&PathBuf::from("/w/probed/sub")), cx); + assert_eq!(place.group, None, "with nothing kept, nothing inherited"); assert_eq!( - app.spawn_group(Some(&PathBuf::from("/w/probed/sub")), cx), - Some(Some(GroupKey::Repo(PathBuf::from("/w/probed")))), - "and with nothing stated the cwd decides again" + place.auto, + Some(Some(AutoKey::Repo(PathBuf::from("/w/probed")))), + "and the cwd seeds the auto group from the warm cache" ); }); } - /// A group is only the set of tabs that name it, so a rename reaching - /// half of them would not leave a half-renamed group — it would leave - /// two groups. + /// A tab opened in a pinned folder is filed there before its shell has + /// said a word — from the cwd it was opened in. #[gpui::test] - fn renaming_a_group_moves_every_tab_in_it_at_once(cx: &mut TestAppContext) { - let (app, mut vcx, _streams) = harness_with_tabs(cx, 3); - let work = GroupKey::custom("work").expect("non-blank"); + fn a_tab_spawned_in_a_pinned_folder_joins_it(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); - app.update_in(&mut vcx, |app, window, cx| { - app.set_tab_group(0, Some(work.clone()), cx); - app.set_tab_group(1, Some(work.clone()), cx); - app.set_tab_group(2, GroupKey::custom("other"), cx); - // Shut it, so the fold state has somewhere to be carried from. - app.toggle_sidebar_group(Some(&work), cx); - app.start_group_rename(work.clone(), window, cx); + app.update(&mut vcx, |app, cx| { + let tty7 = folder(app, "/w/tty7", cx); + let place = app.spawn_group(Some(&PathBuf::from("/w/tty7/src")), cx); + assert_eq!(place.group, Some(tty7)); + let place = app.spawn_group(Some(&PathBuf::from("/w/else")), cx); + assert_eq!(place.group, None); + }); + } + + /// A rename lands on the group itself: its tabs point at it by id, so + /// nothing about them has to change. + #[gpui::test] + fn renaming_a_group_keeps_its_tabs_in_it(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); + + let work = app.update_in(&mut vcx, |app, window, cx| { + let work = label(app, "work", cx); + app.set_tab_group(0, Some(work), cx); + app.set_tab_group(1, Some(work), cx); + app.start_group_rename(work, window, cx); app.group_rename .as_ref() .expect("the box is up") .input .update(cx, |state, cx| state.set_value("urgent", window, cx)); app.commit_group_rename(window, cx); - }); - vcx.run_until_parked(); - - app.update(&mut vcx, |app, cx| { - let renamed = GroupKey::custom("urgent"); - assert_eq!(*app.tabs[0].sidebar_group.borrow(), renamed); - assert_eq!(*app.tabs[1].sidebar_group.borrow(), renamed, "both, in one"); - assert_eq!( - *app.tabs[2].sidebar_group.borrow(), - GroupKey::custom("other"), - "and nothing outside the group moved" - ); - assert_eq!( - cx.global::().sidebar_collapsed_groups, - vec!["custom:urgent".to_string()], - "a shut group that is renamed stays shut, under its new key" - ); - }); - } - - /// Clearing the box and dismissing it must not drop a whole group's - /// worth of tabs into Scratch. - #[gpui::test] - fn a_blank_rename_leaves_the_group_alone(cx: &mut TestAppContext) { - let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); - let work = GroupKey::custom("work").expect("non-blank"); - - app.update_in(&mut vcx, |app, window, cx| { - app.set_tab_group(0, Some(work.clone()), cx); - app.start_group_rename(work.clone(), window, cx); - app.group_rename - .as_ref() - .expect("the box is up") - .input - .update(cx, |state, cx| state.set_value(" ", window, cx)); - app.commit_group_rename(window, cx); + work }); vcx.run_until_parked(); app.update(&mut vcx, |app, _| { + assert_eq!(app.pinned_group_names(), vec![(work, "urgent".to_string())]); + assert!(app.tabs.iter().all(|t| t.group.get() == Some(work))); + }); + } + + /// Clearing the box and dismissing it must not leave a label group with + /// no name to draw; a folder group, which has its folder's name to fall + /// back on, goes back to reading it. + #[gpui::test] + fn a_blank_rename_keeps_a_label_and_resets_a_folder(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + + app.update_in(&mut vcx, |app, window, cx| { + let work = label(app, "work", cx); + let tty7 = folder(app, "/w/tty7", cx); + app.edit_groups(cx, |groups| { + groups.get_mut(tty7).expect("pinned").name = Some("mine".into()) + }); + for id in [work, tty7] { + app.start_group_rename(id, window, cx); + app.group_rename + .as_ref() + .expect("the box is up") + .input + .update(cx, |state, cx| state.set_value(" ", window, cx)); + app.commit_group_rename(window, cx); + } assert_eq!( - *app.tabs[0].sidebar_group.borrow(), - Some(work), - "an empty name means 'keep the one you had'" + app.pinned_group_names(), + vec![(work, "work".to_string()), (tty7, "tty7".to_string())], + "the label kept its name; the folder went back to its own" ); }); } /// The placeholder only has to be unique — the box opens selected, so - /// the first keystroke replaces it. But two groups with one name are one - /// group, so a second new group must not land on top of the first. + /// the first keystroke replaces it. But two headers with one name are two + /// groups nobody can tell apart. #[gpui::test] fn a_second_new_group_does_not_land_on_the_first(cx: &mut TestAppContext) { let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); app.update_in(&mut vcx, |app, window, cx| { app.new_tab_group(0, window, cx); + app.commit_group_rename(window, cx); app.new_tab_group(1, window, cx); + app.commit_group_rename(window, cx); }); vcx.run_until_parked(); app.update(&mut vcx, |app, _| { - let names = app.custom_group_names(); + let names = app.pinned_group_names(); assert_eq!(names.len(), 2, "two groups, not one shared by both tabs"); - assert_ne!(names[0], names[1]); + assert_ne!(names[0].1, names[1].1); + assert_ne!(app.tabs[0].group.get(), app.tabs[1].group.get()); }); } - /// "No grouping" means no headers, full stop. Letting a custom group - /// through would draw its box beside a "Scratch" holding everything else - /// — two headers more than the setting asked for. The key stays on the - /// tab, so turning grouping back on brings it straight back. + /// "Auto grouping off" hides the groups the sidebar works out, and + /// Ungrouped's header with them — but a pinned group is the user's, and + /// still stands. #[gpui::test] - fn no_grouping_hides_a_custom_group_without_forgetting_it(cx: &mut TestAppContext) { - let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); + fn auto_grouping_off_hides_auto_groups_and_keeps_pinned_ones(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 3); - app.update(&mut vcx, |app, cx| { - *app.tabs[0].sidebar_group.borrow_mut() = GroupKey::custom("work"); + let work = app.update(&mut vcx, |app, cx| { + let work = label(app, "work", cx); + app.set_tab_group(0, Some(work), cx); + plant_repo(app, 1, "/w/alpha", "/w/alpha", cx); + plant_plain(app, 2, "/tmp", cx); let mut cfg = cx.global::().clone(); - cfg.sidebar_grouping = SidebarGrouping::None; + cfg.sidebar_auto_grouping = false; cx.set_global(cfg); cx.notify(); + work }); vcx.run_until_parked(); app.update(&mut vcx, |app, cx| { + let keys = app.sidebar_group_keys(cx); + assert_eq!(keys, vec![Some(GroupKey::Pinned(work)), None, None]); + let sections = sidebar_sections(&keys, &app.sidebar_groups); assert_eq!( - app.sidebar_group_keys(cx), - vec![None, None], - "nothing is grouped, so nothing draws a header" - ); - assert_eq!( - *app.tabs[0].sidebar_group.borrow(), - GroupKey::custom("work"), - "but the tab still remembers where it was put" + sections.iter().map(|s| s.name.clone()).collect::>(), + vec![Some("work".into()), None], + "the pinned header, then a flat list with no header over it" ); }); app.update(&mut vcx, |_, cx| { let mut cfg = cx.global::().clone(); - cfg.sidebar_grouping = SidebarGrouping::Repo; + cfg.sidebar_auto_grouping = true; cx.set_global(cfg); cx.notify(); }); vcx.run_until_parked(); app.update(&mut vcx, |app, cx| { + let keys = app.sidebar_group_keys(cx); + assert_eq!( + keys, + vec![Some(GroupKey::Pinned(work)), repo("/w/alpha"), None] + ); + let sections = sidebar_sections(&keys, &app.sidebar_groups); + assert_eq!( + sections.last().and_then(|s| s.name.clone()), + Some("Ungrouped".into()), + "beside an auto group, the rest is Ungrouped again" + ); + }); + } + + /// Rule 2: a tab walking into a pinned folder joins it. + #[gpui::test] + fn a_tab_walking_into_a_pinned_folder_joins_it(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); + + let probed = app.update(&mut vcx, |app, cx| { + plant_plain(app, 0, "/w/elsewhere", cx); + plant_plain(app, 1, "/w/elsewhere", cx); + let probed = folder(app, "/w/probed", cx); + cx.notify(); + probed + }); + vcx.run_until_parked(); + app.update(&mut vcx, |app, cx| { + plant_repo(app, 0, "/w/probed/sub", "/w/probed", cx); + cx.notify(); + }); + vcx.run_until_parked(); + + app.update(&mut vcx, |app, cx| { + assert_eq!(app.tabs[0].group.get(), Some(probed), "walked in: joined"); + assert_eq!(app.tabs[1].group.get(), None, "stayed out: did not"); assert_eq!( app.sidebar_group_keys(cx)[0], - GroupKey::custom("work"), - "turning grouping back on brings the group straight back" + Some(GroupKey::Pinned(probed)), + "and the pinned folder beats the repo root it sits in" ); }); } + /// Nested folders: the deepest one a tab is inside wins, so pinning a + /// monorepo and a package in it files the package's tabs under the + /// package. + #[gpui::test] + fn nested_pinned_folders_file_a_tab_under_the_deepest(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); + + let (mono, pkg) = app.update(&mut vcx, |app, cx| { + let mono = folder(app, "/w/mono", cx); + let pkg = folder(app, "/w/mono/pkg", cx); + plant_repo(app, 0, "/w/mono/pkg/src", "/w/mono", cx); + plant_repo(app, 1, "/w/mono/docs", "/w/mono", cx); + cx.notify(); + (mono, pkg) + }); + vcx.run_until_parked(); + + app.update(&mut vcx, |app, _| { + assert_eq!(app.tabs[0].group.get(), Some(pkg)); + assert_eq!(app.tabs[1].group.get(), Some(mono)); + }); + } + + /// A linked worktree lives outside its main checkout, but its repo home + /// is that checkout — so pinning the repo keeps its worktrees too. + #[gpui::test] + fn a_worktree_joins_the_folder_its_repo_home_is_pinned_as(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + + let tty7 = app.update(&mut vcx, |app, cx| { + let tty7 = folder(app, "/w/tty7", cx); + plant( + app, + 0, + "/tmp/wt/feature/src", + "/tmp/wt/feature", + "/w/tty7", + cx, + ); + cx.notify(); + tty7 + }); + vcx.run_until_parked(); + + app.update(&mut vcx, |app, _| { + assert_eq!(app.tabs[0].group.get(), Some(tty7)); + }); + } + + /// The edge the whole rule turns on. A tab dragged out of a folder group + /// while it is still in the folder has been told where to go; it must not + /// be pulled straight back, and only walking out and in again rejoins it. + #[gpui::test] + fn a_tab_dragged_out_is_not_pulled_back_until_it_re_enters(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + + let probed = app.update(&mut vcx, |app, cx| { + let probed = folder(app, "/w/probed", cx); + plant_repo(app, 0, "/w/probed/sub", "/w/probed", cx); + cx.notify(); + probed + }); + vcx.run_until_parked(); + app.update(&mut vcx, |app, cx| { + assert_eq!( + app.tabs[0].group.get(), + Some(probed), + "joined on the way in" + ); + app.set_tab_group(0, None, cx); + }); + for _ in 0..3 { + app.update(&mut vcx, |_, cx| cx.notify()); + vcx.run_until_parked(); + } + app.update(&mut vcx, |app, cx| { + assert_eq!(app.tabs[0].group.get(), None, "still inside: stays out"); + plant_plain(app, 0, "/w/elsewhere", cx); + cx.notify(); + }); + vcx.run_until_parked(); + app.update(&mut vcx, |app, cx| { + assert_eq!(app.tabs[0].group.get(), None, "left: nothing to join"); + plant_repo(app, 0, "/w/probed/sub", "/w/probed", cx); + cx.notify(); + }); + vcx.run_until_parked(); + app.update(&mut vcx, |app, _| { + assert_eq!(app.tabs[0].group.get(), Some(probed), "re-entered: joined"); + }); + } + + /// A tab restored from the machine tree was filed by whoever had it + /// last. Sitting in a folder at launch is not walking into it. + #[gpui::test] + fn a_restored_tab_is_not_pulled_in_by_where_it_already_is(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + + app.update(&mut vcx, |app, cx| { + app.tabs[0] + .folder_watch + .set(crate::core::group_key::EntryWatch::baseline()); + folder(app, "/w/probed", cx); + plant_repo(app, 0, "/w/probed/sub", "/w/probed", cx); + cx.notify(); + }); + vcx.run_until_parked(); + + app.update(&mut vcx, |app, _| { + assert_eq!(app.tabs[0].group.get(), None); + }); + } + + /// Deleting a group closes nothing: its tabs go back to auto grouping. + #[gpui::test] + fn deleting_a_group_returns_its_tabs_to_auto_grouping(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); + + let work = app.update(&mut vcx, |app, cx| { + let work = label(app, "work", cx); + plant_repo(app, 0, "/w/alpha", "/w/alpha", cx); + app.set_tab_group(0, Some(work), cx); + app.set_tab_group(1, Some(work), cx); + work + }); + vcx.run_until_parked(); + + app.update(&mut vcx, |app, cx| app.delete_group(work, cx)); + vcx.run_until_parked(); + + app.update(&mut vcx, |app, cx| { + assert_eq!(app.tabs.len(), 2, "no tab was closed"); + assert!(app.sidebar_groups.pinned.is_empty()); + assert!(app.tabs.iter().all(|t| t.group.get().is_none())); + assert_eq!(app.sidebar_group_keys(cx)[0], repo("/w/alpha")); + }); + } + + /// Pinning an auto group keeps the tabs in it now, keeps its fold, and — + /// for a repo — keeps its folder, so tabs walking in later join too. + #[gpui::test] + fn pinning_an_auto_group_keeps_its_tabs_and_its_folder(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 3); + + app.update(&mut vcx, |app, cx| { + plant_repo(app, 0, "/w/alpha", "/w/alpha", cx); + plant_repo(app, 1, "/w/alpha/src", "/w/alpha", cx); + plant_repo(app, 2, "/w/beta", "/w/beta", cx); + app.toggle_sidebar_group(repo("/w/alpha").as_ref(), cx); + cx.notify(); + }); + vcx.run_until_parked(); + app.update(&mut vcx, |app, cx| { + app.pin_auto_group(AutoKey::Repo(PathBuf::from("/w/alpha")), cx) + }); + vcx.run_until_parked(); + + app.update(&mut vcx, |app, _| { + let group = app.sidebar_groups.pinned.first().expect("pinned"); + assert_eq!(group.folder.as_deref(), Some("/w/alpha")); + assert!(group.collapsed, "the fold came along"); + assert!(app.sidebar_groups.auto_collapsed.is_empty()); + assert_eq!(app.tabs[0].group.get(), Some(group.id)); + assert_eq!(app.tabs[1].group.get(), Some(group.id)); + assert_eq!(app.tabs[2].group.get(), None, "beta stays auto"); + }); + } + + /// A pinned group with no tabs is still drawn — it is kept until it is + /// deleted — while an auto group with none simply is not there. + #[gpui::test] + fn an_empty_pinned_group_still_has_a_section(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + + app.update(&mut vcx, |app, cx| { + let work = label(app, "work", cx); + let keys = app.sidebar_group_keys(cx); + let sections = sidebar_sections(&keys, &app.sidebar_groups); + assert_eq!(sections[0].key, Some(GroupKey::Pinned(work))); + assert!(sections[0].tabs.is_empty()); + }); + } + #[gpui::test] fn folding_a_group_takes_its_rows_off_the_sidebar(cx: &mut TestAppContext) { let (app, mut vcx, _streams) = harness_with_tabs(cx, 3); - let alpha = GroupKey::Repo(PathBuf::from("/w/alpha")); - let beta = GroupKey::Repo(PathBuf::from("/w/beta")); + let alpha = AutoKey::Repo(PathBuf::from("/w/alpha")); + let beta = AutoKey::Repo(PathBuf::from("/w/beta")); app.update(&mut vcx, |app, cx| { for (i, root) in [(0, &alpha), (1, &alpha), (2, &beta)] { - *app.tabs[i].sidebar_group.borrow_mut() = Some(root.clone()); + *app.tabs[i].auto_group.borrow_mut() = Some(root.clone()); } app.active = 2; cx.notify(); @@ -2576,33 +3695,34 @@ mod fold_tests { ); }); + let alpha_key = GroupKey::Auto(alpha.clone()); app.update(&mut vcx, |app, cx| { - app.toggle_sidebar_group(Some(&alpha), cx) + app.toggle_sidebar_group(Some(&alpha_key), cx) }); vcx.run_until_parked(); - app.update(&mut vcx, |app, cx| { + app.update(&mut vcx, |app, _| { assert!( !drawn(app, 0) && !drawn(app, 1), "the folded group's rows left no rectangle behind" ); assert!(drawn(app, 2), "the group next to it is untouched"); assert_eq!( - cx.global::().sidebar_collapsed_groups, - vec!["/w/alpha".to_string()], - "the fold is written where the next launch will read it" + app.sidebar_groups.auto_collapsed, + vec![alpha.clone()], + "the fold is kept with the workspace, where the next launch reads it" ); }); app.update(&mut vcx, |app, cx| { - app.toggle_sidebar_group(Some(&alpha), cx) + app.toggle_sidebar_group(Some(&alpha_key), cx) }); vcx.run_until_parked(); - app.update(&mut vcx, |app, cx| { + app.update(&mut vcx, |app, _| { assert!((0..3).all(|i| drawn(app, i)), "unfolding brings them back"); assert!( - cx.global::().sidebar_collapsed_groups.is_empty(), + app.sidebar_groups.auto_collapsed.is_empty(), "and takes the entry back out rather than piling up" ); }); @@ -2611,13 +3731,13 @@ mod fold_tests { #[gpui::test] fn a_search_outranks_a_fold(cx: &mut TestAppContext) { let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); - let alpha = GroupKey::Repo(PathBuf::from("/w/alpha")); + let alpha = AutoKey::Repo(PathBuf::from("/w/alpha")); app.update(&mut vcx, |app, cx| { for i in 0..2 { - *app.tabs[i].sidebar_group.borrow_mut() = Some(alpha.clone()); + *app.tabs[i].auto_group.borrow_mut() = Some(alpha.clone()); } - app.toggle_sidebar_group(Some(&alpha), cx); + app.toggle_sidebar_group(Some(&GroupKey::Auto(alpha.clone())), cx); }); vcx.run_until_parked(); app.update(&mut vcx, |app, _| { @@ -2650,11 +3770,11 @@ mod fold_tests { #[gpui::test] fn a_fold_hides_the_active_row_too(cx: &mut TestAppContext) { let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); - let alpha = GroupKey::Repo(PathBuf::from("/w/alpha")); + let alpha = GroupKey::Auto(AutoKey::Repo(PathBuf::from("/w/alpha"))); app.update(&mut vcx, |app, cx| { for i in 0..2 { - *app.tabs[i].sidebar_group.borrow_mut() = Some(alpha.clone()); + *app.tabs[i].auto_group.borrow_mut() = alpha.auto().cloned(); } app.active = 0; app.toggle_sidebar_group(Some(&alpha), cx); @@ -2675,6 +3795,252 @@ mod fold_tests { assert!((0..2).all(|i| drawn(app, i)), "unfolding brings both back"); }); } + /// The divider stands between kept and derived groups, so it is there + /// exactly when something is kept — and it is recorded, because a tab + /// dropped below it leaves its pinned group. + #[gpui::test] + fn the_divider_is_drawn_once_something_is_pinned(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); + app.update(&mut vcx, |_, cx| cx.notify()); + vcx.run_until_parked(); + app.update(&mut vcx, |app, _| { + assert!(app.sidebar_divider.get().is_none(), "nothing kept, no line"); + }); + + app.update(&mut vcx, |app, cx| { + label(app, "work", cx); + }); + vcx.run_until_parked(); + app.update(&mut vcx, |app, _| { + assert!(app.sidebar_divider.get().is_some(), "kept, so a line"); + }); + } + + /// An empty pinned group stays on screen, and takes drops like any other: + /// its block is recorded where a dragged tab looks for somewhere to land. + #[gpui::test] + fn an_empty_pinned_group_is_drawn_and_takes_drops(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + + let work = app.update(&mut vcx, |app, cx| label(app, "work", cx)); + vcx.run_until_parked(); + + app.update(&mut vcx, |app, _| { + let landing = app.sidebar_group_slots.borrow(); + let block = landing + .iter() + .find(|(id, _)| *id == work) + .expect("the empty group was drawn"); + assert!(block.1.size.height > px(0.)); + }); + } + + /// Pinned headers are ordered by hand; a drag lands as a new order for + /// the list, not for the tabs. + #[gpui::test] + fn a_pinned_drag_reorders_the_groups_not_the_tabs(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); + + app.update(&mut vcx, |app, cx| { + let (a, b, c) = ( + label(app, "a", cx), + label(app, "b", cx), + label(app, "c", cx), + ); + app.set_tab_group(0, Some(a), cx); + app.set_tab_group(1, Some(c), cx); + let tabs: Vec<_> = app.tabs.iter().map(|t| t.tree_id.get()).collect(); + app.apply_pinned_order(&[2, 0, 1], cx); + assert_eq!( + app.pinned_group_names() + .into_iter() + .map(|(id, _)| id) + .collect::>(), + vec![c, a, b] + ); + assert_eq!( + app.tabs.iter().map(|t| t.tree_id.get()).collect::>(), + tabs, + "the tabs stayed where they were" + ); + // An order that is not a permutation of the list changes nothing. + app.apply_pinned_order(&[0, 1], cx); + assert_eq!(app.sidebar_groups.pinned.len(), 3); + }); + } + + /// "New Tab" on a pinned group opens a tab in it — joining outright, + /// without waiting for a cwd to walk in, since a label group has nothing + /// to walk into. + #[gpui::test] + fn a_label_group_can_be_filled_from_its_header(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + + let before = app.update(&mut vcx, |app, _| app.tabs.len()); + let work = app.update_in(&mut vcx, |app, window, cx| { + let work = label(app, "work", cx); + app.new_tab_in_group(GroupKey::Pinned(work), window, cx); + work + }); + vcx.run_until_parked(); + + app.update(&mut vcx, |app, _| { + // A harness without a daemon may refuse to spawn; when it does + // open one, it has to be in the group. + if app.tabs.len() > before { + assert_eq!(app.tabs[app.active].group.get(), Some(work)); + } + }); + } + + /// Clearing a folder keeps the name the header was showing: a folder + /// group nobody renamed reads its folder's name, and would read nothing + /// once the folder is gone. + #[gpui::test] + fn clearing_a_folder_keeps_the_name_it_was_showing(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + + app.update(&mut vcx, |app, cx| { + let tty7 = folder(app, "/w/tty7", cx); + app.set_group_folder(tty7, None, cx); + let group = app.sidebar_groups.get(tty7).expect("still pinned"); + assert_eq!(group.folder, None); + assert_eq!(group.given_name(), Some("tty7")); + app.set_group_folder(tty7, Some(PathBuf::from("/w/else")), cx); + let group = app.sidebar_groups.get(tty7).expect("still pinned"); + assert_eq!(group.folder.as_deref(), Some("/w/else")); + assert_eq!(group.given_name(), Some("tty7"), "a set name stays"); + }); + } + /// A folder dropped from Finder is pinned; a file is let fall, and a + /// folder already pinned is not pinned twice. + #[gpui::test] + fn a_dropped_folder_is_pinned_and_a_dropped_file_is_not(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + let dir = tempfile::tempdir().expect("tempdir"); + let file = dir.path().join("notes.txt"); + std::fs::write(&file, "x").expect("write"); + + app.update(&mut vcx, |app, cx| { + let paths = [dir.path().to_path_buf(), file.clone()]; + app.pin_dropped_folders(&paths, cx); + app.pin_dropped_folders(&paths, cx); + let folders: Vec<_> = app + .sidebar_groups + .pinned + .iter() + .map(|g| g.folder.clone()) + .collect(); + assert_eq!( + folders, + vec![Some(dir.path().to_string_lossy().into_owned())] + ); + }); + } + + /// The palette's "New Group" makes an empty pinned group and opens its + /// name for typing. + #[gpui::test] + fn the_palette_makes_an_empty_group_ready_to_name(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + + app.update_in(&mut vcx, |app, window, cx| { + app.new_empty_group(window, cx); + let id = app.sidebar_groups.pinned[0].id; + assert_eq!(app.group_rename.as_ref().map(|r| r.group), Some(id)); + assert!(app.tabs.iter().all(|t| t.group.get().is_none())); + let kinds: Vec<_> = app + .palette_commands(window, cx) + .into_iter() + .map(|c| c.kind) + .collect(); + assert!(kinds.contains(&crate::ui::palette::CommandKind::NewGroup)); + assert!( + kinds.contains(&crate::ui::palette::CommandKind::OpenFolderAsGroup), + "a workspace on this computer can pick a folder" + ); + }); + } + + /// Groups that arrive from another window, or from the machine at launch, + /// are not folders this window's tabs walked into. A tab sitting in one — + /// here, one that was dragged out of it — stays where it is. + #[gpui::test] + fn groups_arriving_from_elsewhere_pull_no_tab_in(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + + app.update(&mut vcx, |app, cx| { + plant_repo(app, 0, "/w/probed/sub", "/w/probed", cx); + cx.notify(); + }); + vcx.run_until_parked(); + app.update(&mut vcx, |app, cx| { + let mut groups = WorkspaceGroups::default(); + groups + .pinned + .push(PinnedGroup::folder(Path::new("/w/probed"))); + app.adopt_sidebar_groups(groups, cx); + }); + vcx.run_until_parked(); + + app.update(&mut vcx, |app, _| { + assert_eq!(app.tabs[0].group.get(), None); + }); + } + + /// A tab restored from the tree is drawn in the auto group it last had + /// before any probe has answered — the launch no longer parks every tab + /// in Ungrouped — and the live answer, once it lands, wins and becomes + /// the new hint. A hint never outranks a pinned group. + #[gpui::test] + fn a_restored_tab_is_drawn_from_its_hint_until_a_probe_answers(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); + + app.update(&mut vcx, |app, cx| { + for i in 0..2 { + let pane = std::mem::replace(&mut app.tabs[i].pane, crate::ui::pane::Pane::Empty); + let tree = tty7_core::core::machine::Tab { + id: app.tabs[i].tree_id.get(), + name: None, + group: None, + last_auto: Some(AutoKey::Repo(PathBuf::from("/w/alpha"))), + root: tty7_core::core::machine::PaneNode::Leaf { pane: 1 }, + hibernated: false, + }; + app.tabs[i] = crate::ui::app::Tab::from_tree(&tree, pane); + } + let work = label(app, "work", cx); + app.tabs[1].group.set(Some(work)); + let keys = app.sidebar_group_keys(cx); + assert_eq!( + keys[0], + repo("/w/alpha"), + "drawn from the hint, no probe yet" + ); + assert_eq!( + keys[1], + Some(GroupKey::Pinned(work)), + "a pinned group outranks any hint" + ); + plant_repo(app, 0, "/w/beta/src", "/w/beta", cx); + cx.notify(); + }); + vcx.run_until_parked(); + + app.update(&mut vcx, |app, cx| { + assert_eq!( + app.sidebar_group_keys(cx)[0], + repo("/w/beta"), + "the probe wins" + ); + let (desired, _, _) = crate::ui::tree_sync::desired_tabs(app, cx); + assert_eq!( + desired[0].last_auto, + Some(AutoKey::Repo(PathBuf::from("/w/beta"))), + "and is what goes up as the next launch's hint" + ); + }); + } } #[cfg(test)] @@ -2685,24 +4051,17 @@ mod tests { PathBuf::from(s) } - /// The derived group keyed on repo root `s`. + /// The auto group keyed on repo root `s`. fn g(s: &str) -> GroupKey { - GroupKey::Repo(p(s)) + GroupKey::Auto(AutoKey::Repo(p(s))) } - /// The group the user named `s` by hand. - fn c(s: &str) -> GroupKey { - GroupKey::custom(s).expect("test names are not blank") + fn host(s: &str) -> GroupKey { + GroupKey::Auto(AutoKey::SshHost(s.into())) } - #[test] - fn the_scratch_group_folds_under_a_key_no_repo_can_take() { - assert_eq!(collapse_key(Some(&g("/w/repo"))), "/w/repo"); - assert_eq!( - collapse_key(None), - "", - "scratch has no root, so it is stored as the name that is not one" - ); + fn none() -> WorkspaceGroups { + WorkspaceGroups::default() } #[test] @@ -2731,64 +4090,56 @@ mod tests { assert_eq!(diff_click_cwd::(&cfg, None), None); } + /// Two machines' `/home/ubuntu` are two directories: each host gets a + /// group of its own, named for the host. #[test] - fn a_probed_non_repo_groups_by_folder_only_in_the_fallback_mode() { - let cwd = p("/w/plain"); + fn ssh_tabs_on_two_hosts_land_in_two_groups() { + let keys = [ + Some(host("ubuntu@alpha")), + Some(host("ubuntu@beta")), + Some(host("ubuntu@alpha")), + ]; + let sections = sidebar_sections(&keys, &none()); + let shape: Vec<(Option, Vec)> = + sections.into_iter().map(|s| (s.name, s.tabs)).collect(); assert_eq!( - resolved_group(SidebarGrouping::RepoOrDirectory, Some(None), &cwd), - Some(Some(g("/w/plain"))) - ); - assert_eq!( - resolved_group(SidebarGrouping::Repo, Some(None), &cwd), - Some(None), - "under Repo a probed non-repo still falls to Scratch" - ); - // Never probed: no decision in either mode, so the tab keeps the - // group it already has instead of bouncing through Scratch. - assert_eq!(resolved_group(SidebarGrouping::Repo, None, &cwd), None); - assert_eq!( - resolved_group(SidebarGrouping::RepoOrDirectory, None, &cwd), - None + shape, + vec![ + (Some("ubuntu@alpha".into()), vec![0, 2]), + (Some("ubuntu@beta".into()), vec![1]), + ] ); } #[test] - fn a_native_ssh_cwd_groups_by_its_remote_folder() { - let home = p("/home/ubuntu"); + fn a_shell_that_sshd_onward_groups_by_host_and_wsl_does_not() { + use crate::daemon::protocol::{RemoteContext, RemoteKind}; + let ctx = |kind| RemoteContext { + kind, + argv: vec![], + target: "u@h".into(), + }; assert_eq!( - unprobed_group(SidebarGrouping::RepoOrDirectory, &home), - Some(Some(g("/home/ubuntu"))) + ssh_host(Some(&ctx(RemoteKind::Ssh))).as_deref(), + Some("u@h") ); assert_eq!( - unprobed_group(SidebarGrouping::Repo, &home), - Some(None), - "under Repo there is no repo to find, so Scratch" + ssh_host(Some(&ctx(RemoteKind::NativeSsh))).as_deref(), + Some("u@h") ); - // Named by the last segment, like a local folder group. - let keys = [Some(g("/home/ubuntu")), None]; - let sections = sidebar_sections(&keys); - assert_eq!(sections[0].name.as_deref(), Some("ubuntu")); + assert_eq!(ssh_host(Some(&ctx(RemoteKind::Wsl))), None); + assert_eq!(ssh_host(None), None); } #[test] - fn a_known_repo_home_wins_over_the_folder_in_both_modes() { - for mode in [SidebarGrouping::Repo, SidebarGrouping::RepoOrDirectory] { - assert_eq!( - resolved_group(mode, Some(Some(p("/w/repo"))), &p("/w/repo/sub")), - Some(Some(g("/w/repo"))) - ); - } - } - - #[test] - fn sections_order_groups_by_first_appearance_scratch_last() { + fn sections_order_groups_by_first_appearance_ungrouped_last() { let keys = vec![ Some(g("/w/beta")), None, Some(g("/w/alpha")), Some(g("/w/beta")), ]; - let sections = sidebar_sections(&keys); + let sections = sidebar_sections(&keys, &none()); let shape: Vec<(Option, Option, Vec)> = sections .into_iter() .map(|s| (s.key, s.name, s.tabs)) @@ -2798,16 +4149,63 @@ mod tests { vec![ (Some(g("/w/beta")), Some("beta".into()), vec![0, 3]), (Some(g("/w/alpha")), Some("alpha".into()), vec![2]), - (None, Some("Scratch".into()), vec![1]), + (None, Some("Ungrouped".into()), vec![1]), ] ); - let flat = sidebar_sections(&[None, None]); + let flat = sidebar_sections(&[None, None], &none()); assert_eq!(flat.len(), 1); assert_eq!(flat[0].name, None); assert_eq!(flat[0].tabs, vec![0, 1]); } + /// Pinned groups come first, in the order the user gave them, whatever + /// order their tabs are in — and an empty one keeps its place. + #[test] + fn pinned_groups_lead_in_their_own_order_empty_or_not() { + let mut groups = none(); + let (a, b, c) = ( + PinnedGroup::label("a"), + PinnedGroup::folder(Path::new("/w/b")), + PinnedGroup::label("c"), + ); + groups.pinned = vec![a.clone(), b.clone(), c.clone()]; + let keys = vec![ + Some(g("/w/r")), + Some(GroupKey::Pinned(c.id)), + Some(GroupKey::Pinned(a.id)), + None, + ]; + let sections = sidebar_sections(&keys, &groups); + let shape: Vec<(Option, Vec)> = + sections.into_iter().map(|s| (s.name, s.tabs)).collect(); + assert_eq!( + shape, + vec![ + (Some("a".into()), vec![2]), + (Some("b".into()), vec![]), + (Some("c".into()), vec![1]), + (Some("r".into()), vec![0]), + (Some("Ungrouped".into()), vec![3]), + ] + ); + } + + /// With pinned groups and nothing auto-grouped below them, the rest is + /// just the list — a header reading "Ungrouped" over all of it would be a + /// label on nothing. + #[test] + fn below_pinned_groups_the_rest_needs_no_header_on_its_own() { + let mut groups = none(); + let work = PinnedGroup::label("work"); + groups.pinned = vec![work.clone()]; + let keys = vec![Some(GroupKey::Pinned(work.id)), None]; + let sections = sidebar_sections(&keys, &groups); + assert_eq!(sections.len(), 2); + assert_eq!(sections[1].name, None); + assert_eq!(sections[1].tabs, vec![1]); + } + /// 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 @@ -2821,7 +4219,7 @@ mod tests { Some(g("/w/beta")), ]; // What `visual_tab_order` returns for a left tab bar. - let order: Vec = sidebar_sections(&keys) + let order: Vec = sidebar_sections(&keys, &none()) .into_iter() .flat_map(|s| s.tabs) .collect(); @@ -2852,14 +4250,14 @@ mod tests { ]; let alpha = Some(g("/w/alpha")); assert_eq!( - reordered_rows(&keys, &alpha, &[0, 2], 0, 1), + reordered_rows(&keys, &none(), &alpha, &[0, 2], 0, 1), Some(vec![2, 0, 1, 3]) ); assert_eq!( - reordered_rows(&keys, &alpha, &[0, 2], 1, 0), + reordered_rows(&keys, &none(), &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(&keys, &none(), &alpha, &[0, 2], 1, 1), None); } #[test] @@ -2867,11 +4265,26 @@ mod tests { let keys = vec![Some(g("/w/a")), Some(g("/w/a")), Some(g("/w/a"))]; let a = Some(g("/w/a")); assert_eq!( - reordered_rows(&keys, &a, &[0, 2], 0, 1), + reordered_rows(&keys, &none(), &a, &[0, 2], 0, 1), Some(vec![1, 2, 0]) ); } + /// A pinned group is where a tab was put, and rows move within it the + /// same way they move within a repo group. + #[test] + fn rows_reorder_inside_a_pinned_group_too() { + let mut groups = none(); + let work = PinnedGroup::label("work"); + groups.pinned = vec![work.clone()]; + let w = Some(GroupKey::Pinned(work.id)); + let keys = vec![w.clone(), Some(g("/w/beta")), w.clone()]; + assert_eq!( + reordered_rows(&keys, &groups, &w, &[0, 2], 0, 1), + Some(vec![2, 0, 1]) + ); + } + #[test] fn regrouped_order_moves_the_group_into_the_target_slot() { let keys = vec![ @@ -2881,12 +4294,13 @@ mod tests { Some(g("/w/alpha")), Some(g("/w/gamma")), ]; + let r = |s: &str| AutoKey::Repo(p(s)); assert_eq!( - regrouped_order(&keys, &g("/w/gamma"), &g("/w/alpha")), + regrouped_order(&keys, &r("/w/gamma"), &r("/w/alpha")), Some(vec![4, 0, 3, 2, 1]) ); assert_eq!( - regrouped_order(&keys, &g("/w/alpha"), &g("/w/gamma")), + regrouped_order(&keys, &r("/w/alpha"), &r("/w/gamma")), Some(vec![2, 4, 0, 3, 1]) ); } @@ -2894,74 +4308,48 @@ mod tests { #[test] fn regrouped_order_ignores_self_and_unknown_roots() { let keys = vec![Some(g("/w/alpha")), Some(g("/w/beta"))]; - assert_eq!(regrouped_order(&keys, &g("/w/alpha"), &g("/w/alpha")), None); - assert_eq!(regrouped_order(&keys, &g("/w/gone"), &g("/w/beta")), None); - assert_eq!(regrouped_order(&keys, &g("/w/alpha"), &g("/w/gone")), None); + let r = |s: &str| AutoKey::Repo(p(s)); + assert_eq!(regrouped_order(&keys, &r("/w/alpha"), &r("/w/alpha")), None); + assert_eq!(regrouped_order(&keys, &r("/w/gone"), &r("/w/beta")), None); + assert_eq!(regrouped_order(&keys, &r("/w/alpha"), &r("/w/gone")), None); } - /// A custom name is the name the user typed. Running it through the path + #[test] + fn reordered_pinned_moves_a_header_among_the_ones_shown() { + let ids: Vec = (0..4).map(|_| GroupId::new()).collect(); + // All four shown: dragging the first onto the third. + assert_eq!(reordered_pinned(&ids, &ids, 0, 2), Some(vec![1, 2, 0, 3])); + assert_eq!(reordered_pinned(&ids, &ids, 3, 0), Some(vec![3, 0, 1, 2])); + assert_eq!(reordered_pinned(&ids, &ids, 1, 1), None); + // A search hides the second; it keeps its place. + let shown = [ids[0], ids[2], ids[3]]; + assert_eq!(reordered_pinned(&ids, &shown, 2, 0), Some(vec![3, 0, 1, 2])); + } + + /// A label is the name the user typed. Running it through the path /// splitter would chop one containing a `/` into components and then /// "shorten" it to the tail, so `work/urgent` would print as `urgent`. #[test] - fn a_custom_name_is_never_shortened_the_way_a_path_is() { - let keys = vec![Some(c("work/urgent")), Some(g("/home/u/tty7"))]; - let sections = sidebar_sections(&keys); - assert_eq!( - sections.iter().map(|s| s.name.clone()).collect::>(), - vec![Some("work/urgent".into()), Some("tty7".into())] - ); - } - - /// Two repo roots ending in the same component grow a prefix until they - /// differ. A custom group sitting between them takes no part in that: it - /// is not a path, so there is no prefix to grow and nothing to collide - /// with as far as the path splitter is concerned. - #[test] - fn a_custom_group_sits_out_the_path_disambiguation() { - let keys = vec![ - Some(g("/home/u/work/app")), - Some(c("scratch")), - Some(g("/home/u/fork/app")), + fn a_label_is_never_shortened_the_way_a_path_is() { + let pinned = vec![ + PinnedGroup::label("work/urgent"), + PinnedGroup::folder(Path::new("/home/u/tty7")), ]; - let sections = sidebar_sections(&keys); - assert_eq!( - sections.iter().map(|s| s.name.clone()).collect::>(), - vec![ - Some("work/app".into()), - Some("scratch".into()), - Some("fork/app".into()) - ], - "the two roots still disambiguate against each other" - ); + assert_eq!(pinned_names(&pinned), vec!["work/urgent", "tty7"]); } - /// A custom group named after a real repo prints the same header as that - /// repo, but the two are different groups and must stay apart — in the - /// section list and, through `collapse_key`, in the fold state. + /// Two folders ending in the same component grow a prefix until they + /// differ, the way two repo roots do; a named one sits that out. #[test] - fn a_custom_group_never_merges_with_the_repo_it_is_named_after() { - let keys = vec![Some(g("/w/tty7")), Some(c("tty7"))]; - let sections = sidebar_sections(&keys); - assert_eq!(sections.len(), 2, "two groups, not one"); - assert_eq!(sections[0].tabs, vec![0]); - assert_eq!(sections[1].tabs, vec![1]); - assert_ne!( - collapse_key(sections[0].key.as_ref()), - collapse_key(sections[1].key.as_ref()), - "folding one must not fold the other" - ); - } - - /// A custom group is where a tab was put, and rows move within it the - /// same way they move within a repo group. - #[test] - fn rows_reorder_inside_a_custom_group_too() { - let keys = vec![Some(c("work")), Some(g("/w/beta")), Some(c("work"))]; - let work = Some(c("work")); - assert_eq!( - reordered_rows(&keys, &work, &[0, 2], 0, 1), - Some(vec![2, 0, 1]) - ); + fn pinned_folders_disambiguate_like_repo_roots() { + let mut named = PinnedGroup::folder(Path::new("/home/u/other/app")); + named.name = Some("mine".into()); + let pinned = vec![ + PinnedGroup::folder(Path::new("/home/u/work/app")), + named, + PinnedGroup::folder(Path::new("/home/u/fork/app")), + ]; + assert_eq!(pinned_names(&pinned), vec!["work/app", "mine", "fork/app"]); } #[test] diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index ddb712f7..bc8f4805 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -18,8 +18,7 @@ use crate::core::actions::{ SelectWorkspace6, SelectWorkspace7, SelectWorkspace8, SelectWorkspace9, SplitDown, SplitRight, TogglePalette, }; -use crate::core::config::{Config, RightPanelTab, SidebarGrouping}; -use crate::core::group_key::GroupKey; +use crate::core::config::{Config, RightPanelTab}; use crate::core::shells::DetectedShell; use crate::daemon::protocol::ShellSpec; use crate::ui::app::{SpawnWhere, TILE_GLYPH, TILE_SIZE, Tab, Tty7App, tile_trailing_inset}; @@ -1822,36 +1821,34 @@ impl Tty7App { // Where this tab sits, and where it could be put instead. // // Laid out flat rather than behind a "Move to Group ▸" submenu: there - // are never many custom groups — they are maintained by hand — so a - // submenu would cost a second click to show two or three items, and + // are never many pinned groups — they are kept by hand — so a submenu + // would cost a second click to show two or three items, and // `PopupMenu::submenu` wants a `&mut Context` this function does not // have. The label above them says what the block is. // - // Hidden entirely when grouping is off. The sidebar draws no headers - // then, so "move to group" would name something the user cannot see. - if cx.global::().sidebar_grouping != SidebarGrouping::None { - let stated = this + // No way back to auto grouping here: that is a drag below the divider, + // the one place in the sidebar where "not kept by hand" is drawn. + // Offered only with the tabs in the sidebar for the same reason — a + // group is something the sidebar draws, and "move to group" from the + // top tab bar would name something the user cannot see. + if cx.global::().tab_bar_position == crate::core::config::TabBarPosition::Left { + let here = this .tabs .get(index) - .and_then(|t| t.sidebar_group.borrow().clone()); - let here = match &stated { - Some(GroupKey::Custom(name)) => Some(name.clone()), - _ => None, - }; + .and_then(|t| t.group.get()) + .filter(|g| this.sidebar_groups.contains(*g)); menu = menu .separator() .item(PopupMenuItem::label(t(L10nKey::SidebarMoveToGroup))); - for name in this.custom_group_names() { + for (id, name) in this.pinned_group_names() { menu = menu.item( - PopupMenuItem::new(name.clone()) - .checked(here.as_deref() == Some(name.as_str())) + PopupMenuItem::new(name) + .checked(here == Some(id)) .on_click({ let app = app.clone(); - let name = name.clone(); move |_, _window, cx| { - let key = GroupKey::custom(&name); - let _ = - app.update(cx, |this, cx| this.set_tab_group(index, key, cx)); + let _ = app + .update(cx, |this, cx| this.set_tab_group(index, Some(id), cx)); } }), ); @@ -1862,16 +1859,6 @@ impl Tty7App { let _ = app.update(cx, |this, cx| this.new_tab_group(index, window, cx)); } })); - // Only worth offering once there is something to undo. A tab that - // never left its derived group is already grouped automatically. - if here.is_some() { - menu = menu.item(PopupMenuItem::new(t(L10nKey::SidebarAutoGroup)).on_click({ - let app = app.clone(); - move |_, _window, cx| { - let _ = app.update(cx, |this, cx| this.set_tab_group(index, None, cx)); - } - })); - } } let in_repo = this.tab_is_in_repo(index, window, cx); diff --git a/src/ui/tree_sync.rs b/src/ui/tree_sync.rs index b98e70b7..5fc8931c 100644 --- a/src/ui/tree_sync.rs +++ b/src/ui/tree_sync.rs @@ -11,7 +11,7 @@ use tty7_core::core::machine::{ use tty7_core::daemon::control::{ControlClient, ControlRequest, ReplyOk}; use tty7_core::host::HostId; -use crate::core::group_key::GroupKey; +use crate::core::group_key::{AutoKey, GroupId, WorkspaceGroups}; use crate::core::session::{Session, SessionPane, SessionTab, WorkspaceId, WorkspaceStore}; use crate::ui::app::Tty7App; use crate::ui::i18n::{L10nKey, t}; @@ -75,7 +75,8 @@ fn tree_workspace_id(cx: &App, client_ws: WorkspaceId) -> WorkspaceId { pub(crate) struct DesiredTab { pub id: TabId, pub name: Option, - pub group: Option, + pub group: Option, + pub last_auto: Option, pub root: DesiredNode, /// The tab is asleep, and `root` is the layout it will wake into — the /// same pane ids the machine already holds, none of them running. @@ -156,7 +157,12 @@ pub(crate) fn desired_tabs( out.push(DesiredTab { id, name: tab.name.clone(), - group: tab.sidebar_group.borrow().as_ref().map(GroupKey::encode), + // As the tab has it, even when this window does not know the + // group: a window whose copy of the groups has not landed yet + // would otherwise send every tab it holds back to auto grouping. + // A group that really is gone was already cleared by the machine. + group: tab.group.get(), + last_auto: tab.auto_group.borrow().clone(), root, hibernated: tab.asleep_layout().is_some(), }); @@ -333,6 +339,7 @@ fn seeded_records(desired: &[DesiredTab], live: impl Fn(u64) -> bool) -> Vec, pub active: Option, + pub groups: WorkspaceGroups, } #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -575,11 +582,12 @@ fn create_tab( name: want.name.clone(), }); } - if want.group.is_some() { + if want.group.is_some() || want.last_auto.is_some() { ops.push(ControlRequest::TabSetGroup { workspace, tab: want.id, - group: want.group.clone(), + group: want.group, + last_auto: want.last_auto.clone(), }); } if want.hibernated { @@ -594,7 +602,8 @@ fn create_tab( TreeTab { id: want.id, name: want.name.clone(), - sidebar_group: want.group.clone(), + group: want.group, + last_auto: want.last_auto.clone(), root, hibernated: want.hibernated, }, @@ -643,12 +652,17 @@ fn reconcile_tab( name: want.name.clone(), }); } - if tab.sidebar_group != want.group { - tab.sidebar_group = want.group.clone(); + // One op for both: the hint rides with the membership, so a tab + // whose repo changed costs one message, and the machine never holds + // a hint and a group that came from two different moments. + if tab.group != want.group || tab.last_auto != want.last_auto { + tab.group = want.group; + tab.last_auto = want.last_auto.clone(); ops.push(ControlRequest::TabSetGroup { workspace, tab: want.id, - group: want.group.clone(), + group: want.group, + last_auto: want.last_auto.clone(), }); } // Ahead of anything structural. Going to sleep changes nothing else @@ -1048,6 +1062,9 @@ struct WsState { /// `start_prime`, which spends it instead of the generated name, and /// cleared by `finish_prime` once the machine has confirmed a name. chosen_name: Option, + /// An edit to the workspace's sidebar groups made before this window's + /// pull landed, sent on when it does — see [`push_groups`]. + unsent_groups: Option, /// Whether this window has already been told why it opened empty. /// /// The retry is as quiet as the failure was, so a window whose machine @@ -1075,6 +1092,7 @@ impl Default for WsState { rehydrate_attempts: 0, then_open: Vec::new(), chosen_name: None, + unsent_groups: None, said_why_empty: false, } } @@ -1552,6 +1570,7 @@ fn primed(ws: Workspace, arrival: Arrival) -> (WsMirror, Option, Arrival WsMirror { tabs: ws.tabs, active: ws.active_tab, + groups: ws.groups, }, ws.name, arrival, @@ -1591,6 +1610,7 @@ fn finish_prime( return; } }; + adopt_groups(cx, client_ws); let host = WorkspaceStore::host_of(cx, client_ws); let machine_ws = tree_workspace_id(cx, client_ws); crate::ui::machine_mirror::MachineMirrors::note_synced_workspace( @@ -1611,6 +1631,87 @@ fn finish_prime( app.update(cx, |app, cx| sync_window(app, cx)); } +/// Sends a window's edit to its workspace's sidebar groups up to the machine. +/// +/// Not part of the diff [`sync_window`] runs, on purpose. Groups have nothing +/// a window can know better than the machine: a fresh window holds none at +/// all, and diffing that emptiness against the tree would unpin every group +/// the workspace had. So they only ever go up as what they are — an edit +/// someone just made — and come down from every pull and every +/// `GroupsChanged`. +/// +/// Queued with the tab ops rather than fired beside them, and ahead of the +/// ones the same edit raises (the caller pushes this before it saves): a tab +/// filed into a group that was just made must not reach the machine before +/// the group does, or `workspace_set_groups`, which hands tabs naming unknown +/// groups back to auto grouping, would see a tab pointing at nothing. +/// +/// A window whose pull has not landed parks the edit, and the pull sends it +/// on instead of overwriting it — otherwise a pin made in the first moments +/// of a window's life would be undone by the tree that arrives after it. +pub(crate) fn push_groups(cx: &mut App, client_ws: WorkspaceId, groups: WorkspaceGroups) { + if !cx.has_global::() { + return; + } + let machine_ws = tree_workspace_id(cx, client_ws); + let state = cx + .default_global::() + .windows + .entry(client_ws) + .or_default(); + match &mut state.sync { + SyncPhase::Primed(mirror) => { + if mirror.groups == groups { + return; + } + mirror.groups = groups.clone(); + state.queue.push_back(ControlRequest::WorkspaceSetGroups { + workspace: machine_ws, + groups, + }); + pump(cx, client_ws); + } + SyncPhase::Unprimed { .. } => state.unsent_groups = Some(groups), + } +} + +/// Hands the window the sidebar groups a pull just brought in — or, when the +/// window edited them before the pull landed, sends that edit up instead. +/// +/// Deferred: a pull can land while the window is itself mid-update (its own +/// `sync_window` started it), and the window is only updated once that is +/// over. Nothing in between can push the window's stale copy, because groups +/// only ever go up from an edit — see [`push_groups`]. +fn adopt_groups(cx: &mut App, client_ws: WorkspaceId) { + let Some(state) = cx.default_global::().windows.get_mut(&client_ws) else { + return; + }; + if let Some(unsent) = state.unsent_groups.take() { + push_groups(cx, client_ws, unsent); + return; + } + cx.defer(move |cx| { + let groups = match cx + .default_global::() + .windows + .get(&client_ws) + .map(|s| &s.sync) + { + Some(SyncPhase::Primed(mirror)) => mirror.groups.clone(), + _ => return, + }; + if !cx.has_global::() { + return; + } + let Some(app) = + crate::ui::windows::WindowRegistry::app_for(cx, client_ws).and_then(|a| a.upgrade()) + else { + return; + }; + app.update(cx, |app, cx| app.adopt_sidebar_groups(groups, cx)); + }); +} + fn pump(cx: &mut App, client_ws: WorkspaceId) { let host = WorkspaceStore::host_of(cx, client_ws); let client = tree_control_for(cx, host); @@ -1690,7 +1791,8 @@ pub(crate) fn session_from_tree( .map(|(tab, view)| SessionTab { name: tab.name.clone(), tree_id: Some(tab.id), - sidebar_group: tab.sidebar_group.as_deref().and_then(GroupKey::decode), + group: tab.group, + last_auto: tab.last_auto.clone(), pane: session_pane_from_node(&tab.root, panes), hibernated: tab.hibernated, asleep_view: tab.hibernated.then_some(view), @@ -2276,6 +2378,7 @@ fn layout_of( let mirror = WsMirror { tabs: ws.tabs.clone(), active: ws.active_tab, + groups: ws.groups.clone(), }; let session = session_from_tree(ws, &machine.panes); Ok((machine, mirror, session)) @@ -2356,6 +2459,7 @@ fn settle_hydration( state.said_why_empty = false; dirty }; + adopt_groups(cx, client_ws); let Some(app) = crate::ui::windows::WindowRegistry::app_for(cx, client_ws).and_then(|app| app.upgrade()) else { @@ -2621,11 +2725,20 @@ fn apply_to_mirror(mirror: &mut WsMirror, delta: &LayoutDelta) -> bool { t.name = name.clone(); true } - LayoutDelta::TabRegrouped { tab, group } => { + LayoutDelta::TabRegrouped { + tab, + group, + last_auto, + } => { let Some(t) = mirror.tabs.iter_mut().find(|t| t.id == *tab) else { return false; }; - t.sidebar_group = group.clone(); + t.group = *group; + t.last_auto = last_auto.clone(); + true + } + LayoutDelta::GroupsChanged { groups } => { + mirror.groups = groups.clone(); true } LayoutDelta::TabMoved { tab, to } => { @@ -2779,13 +2892,29 @@ impl Tty7App { } true } - LayoutDelta::TabRegrouped { tab, group } => { + LayoutDelta::TabRegrouped { + tab, + group, + last_auto, + } => { if let Some(index) = index_of(&self.tabs, *tab) { - *self.tabs[index].sidebar_group.borrow_mut() = - group.as_deref().and_then(GroupKey::decode); + let gui = &self.tabs[index]; + gui.group.set(*group); + // Another window's hint only fills a gap. Where this + // window has an answer of its own it keeps it: the two + // probe the same cwd and agree, and letting each + // overwrite the other would bounce a disagreement between + // them for as long as it lasted. + if gui.auto_group.borrow().is_none() { + *gui.auto_group.borrow_mut() = last_auto.clone(); + } } true } + LayoutDelta::GroupsChanged { groups } => { + self.adopt_sidebar_groups(groups.clone(), cx); + 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()); @@ -2887,7 +3016,10 @@ impl Tty7App { let gui = &mut self.tabs[index]; gui.pane = pane; gui.name = tab.name.clone(); - *gui.sidebar_group.borrow_mut() = tab.sidebar_group.as_deref().and_then(GroupKey::decode); + gui.group.set(tab.group); + if gui.auto_group.borrow().is_none() { + *gui.auto_group.borrow_mut() = tab.last_auto.clone(); + } self.maximized = None; true } @@ -3546,12 +3678,17 @@ 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, + ..Default::default() + }) + }; let a_tab = || TreeTab { id: TabId::new(), name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Leaf { pane: 1 }, hibernated: false, }; @@ -3621,10 +3758,7 @@ mod tests { // The mirror the emptied window would have been diffed against, // 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 { - tabs: vec![], - active: None, - }); + state.sync = SyncPhase::Primed(WsMirror::default()); state .queue .push_back(ControlRequest::TabClose { workspace: ws, tab }); @@ -4061,7 +4195,7 @@ mod tests { state.rehydrate = None; state.sync = SyncPhase::Primed(WsMirror { tabs: vec![TreeTab::leaf(1), TreeTab::leaf(2)], - active: None, + ..Default::default() }); } assert!( @@ -4190,19 +4324,22 @@ mod tests { TreeTab { id: put_up, name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Leaf { pane: 1 }, hibernated: false, }, TreeTab { id: failed, name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Leaf { pane: 2 }, hibernated: false, }, ], active: Some(put_up), + ..Default::default() }); // Keeps whatever the sync queues where the test can read it: // with no link, `pump` would otherwise clear the queue and drop @@ -4282,19 +4419,22 @@ mod tests { TreeTab { id: theirs.0, name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Leaf { pane: 11 }, hibernated: false, }, TreeTab { id: theirs.1, name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Leaf { pane: 12 }, hibernated: false, }, ], active: Some(theirs.0), + ..Default::default() }); state.informed = true; // Keeps whatever the switch queues where the test can read it. @@ -4365,7 +4505,7 @@ mod tests { }; let advanced = WsMirror { tabs: vec![TreeTab::leaf(7)], - active: None, + ..Default::default() }; { let state = cx @@ -4425,6 +4565,7 @@ mod tests { id, name: None, group: None, + last_auto: None, root, hibernated: false, } @@ -4455,7 +4596,7 @@ mod tests { for (m, d) in mirror.tabs.iter().zip(desired) { assert_eq!(m.id, d.id); assert_eq!(m.name, d.name); - assert_eq!(m.sidebar_group, d.group); + assert_eq!(m.group, d.group); assert_eq!(m.root, d.root.to_pane_node()); } } @@ -4919,7 +5060,8 @@ mod tests { let mut named = tab(id, leaf(1)); named.name = Some("build".into()); - named.group = Some("/repo".into()); + let group = GroupId::new(); + named.group = Some(group); let want = vec![named]; let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]); assert_eq!( @@ -4933,7 +5075,8 @@ mod tests { ControlRequest::TabSetGroup { workspace: ws, tab: id, - group: Some("/repo".into()), + group: Some(group), + last_auto: None, }, ] ); @@ -5089,7 +5232,8 @@ mod tests { let tree_tab = TreeTab { id, name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Leaf { pane: 1 }, hibernated: false, }; @@ -5110,7 +5254,8 @@ mod tests { tab: TreeTab { id, name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Split { axis: TreeAxis::Vertical, ratio: 0.5, @@ -5180,7 +5325,8 @@ mod tests { tabs: vec![TreeTab { id: tab_id, name: Some("build".into()), - sidebar_group: Some("/repo".into()), + group: Some(GroupId::new()), + last_auto: Some(AutoKey::Repo("/work".into())), root: PaneNode::Split { axis: TreeAxis::Vertical, ratio: 0.3, @@ -5223,6 +5369,11 @@ mod tests { "the daemon tab's identity rides along" ); assert_eq!(tab.name.as_deref(), Some("build")); + assert_eq!( + tab.last_auto, + Some(AutoKey::Repo("/work".into())), + "the auto-group hint rides along, so the tab is drawn in its group at once" + ); let SessionPane::Split { ratio, a, b, .. } = &tab.pane else { panic!("the split survives the lowering"); }; @@ -5276,7 +5427,8 @@ mod tests { tabs: vec![TreeTab { id: tab_id, name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Leaf { pane: 7 }, hibernated: false, }], @@ -5306,7 +5458,8 @@ mod tests { tabs: vec![TreeTab { id: TabId::new(), name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Leaf { pane: 1 }, hibernated: false, }], @@ -5536,14 +5689,16 @@ mod tests { TreeTab { id: awake, name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Leaf { pane: 1 }, hibernated: false, }, TreeTab { id: sleeping, name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Leaf { pane: 2 }, hibernated: true, },