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.
-## 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,
},
| |