feat(sidebar): pinned groups above auto groups (#962)

* feat(sidebar): keep pinned groups on the workspace and derive the rest

Replace the sidebar's hand-made groups with the model from #955: the sidebar
groups tabs by repo automatically, and you pin what you want to keep.

- Pinned groups (`PinnedGroup`: id, optional name, optional folder, fold) are
  stored on the workspace in the machine tree, in display order, and a tab
  points at one by `GroupId`. Everything else is an auto group worked out
  every frame and never stored: by repo home, or by `user@host` for an SSH
  pane — native or a shell that ssh'd onward — so `/home/ubuntu` on two
  machines no longer lands under one header.
- A tab whose cwd *enters* a pinned folder joins it (deepest folder wins; a
  repo home equal to the folder counts, which keeps worktrees with their
  repo). It is edge-triggered through `EntryWatch`, so a tab dragged out
  while still inside the folder stays out until it leaves and comes back, and
  a tab restored at launch is not pulled in by where it already sits.
- Groups sync as one `WorkspaceSetGroups` / `GroupsChanged`, pushed only from
  an edit and adopted from every pull, so a fresh window can never push an
  empty set over the workspace's. The machine hands tabs naming a dropped
  group back to auto grouping in the same mutation. Control dialect → v11.
- Config: `sidebar_grouping` (three modes) and `sidebar_collapsed_groups` give
  way to one `sidebar_auto_grouping` toggle; folds live with the workspace.
- `tty7 tab ls` reports the pinned group a tab is in (name or folder leaf;
  JSON carries id, name and folder).

* feat(sidebar): draw pinned groups above a divider, with their own gestures

The sidebar now reads as two halves: the groups you keep, in the order you
put them, then a divider, then the groups it works out (Arc-style).

- Pinned headers drag-reorder among themselves (their own reorder surface, so
  a pinned header cannot be dropped among the derived ones); the order lands
  on the workspace's group list, not on the tabs.
- An auto header carried above the divider is pinned when let go. With
  nothing pinned yet the divider appears during that drag as a "Drop here to
  pin" zone, since a hairline at the top of the list is nothing to aim at.
- A tab kept in a pinned group and dropped anywhere below the divider goes
  back to auto grouping; the divider lights to say so.
- An empty pinned group stays, with a "+ New Tab" row that opens a tab in its
  folder (or where ⌘T would, for a label group) and files it there.
- Folder groups carry a pin mark that unpins on click and a tooltip with the
  folder; auto headers show pin and "+" on hover.
- Header menus: pinned — Rename, Set Folder… (local workspaces), Use Current
  Tab's Folder, Clear Folder, New Tab, Unpin (folder groups), Delete. Auto —
  Pin Group, New Tab. Nothing renames an auto group; nothing pins implicitly.

* feat(sidebar): open folders as pinned groups from Finder, the file tree and the palette

Every way into a pinned group the design calls for:

- Drop a folder from Finder or Explorer onto the sidebar to pin it (a local
  workspace only — a dropped path is this machine's, and a folder group keeps
  a directory on the workspace's host). Files are let fall.
- "Pin as Group" on a folder in the file tree, on local and remote workspaces
  alike, since the tree and the group are both on the workspace's host.
- Palette "New Group" makes an empty label group and opens its name for
  typing; "Open Folder as Group…" picks a folder with the system picker, pins
  it and opens a tab in it. The picker browses this computer, so that one is
  not offered on a remote workspace.
- Tab right-click "Move to Group" lists the pinned groups plus "New Group…",
  which files the tab in a fresh label group with its name open for typing.

Pinning a folder already pinned hands back the group that keeps it rather
than making a second one to split its tabs with.

* fix(sidebar): let groups that arrive from elsewhere pull no tab into a folder

A window draws its first frames before its copy of the workspace's groups
lands, so every tab's entry watch recorded "in no folder" — and the groups
landing then read as each tab walking into its folder. A restored tab, or one
dragged out of its folder group, was pulled back in on every launch.

Groups adopted from a pull or from another window's `GroupsChanged` now start
every tab's watch over from where it is; only this window's own pin gathers
the tabs inside the folder, and says so tab by tab. A tab also goes up with
the group it names even when the window does not know that group yet, so a
sync in that same gap cannot send every kept tab back to auto grouping.

* docs(sidebar): describe pinned and auto groups, and log the change

Rewrite the sidebar page's grouping section around "grouped by repo
automatically; pin what you want to keep": the divider, folder and label
groups, the edge-triggered join, every way to pin, and the header menus. The
configuration reference swaps `sidebar_grouping` for `sidebar_auto_grouping`,
the CLI reference describes the GROUP column as the pinned group, and the
changelog gains an Unreleased entry (#955).

* fix(sidebar): file a tab opened by the CLI in a pinned folder into it

A tab that reaches a window as TabCreated — from `tty7 tab new` or another
window — started its entry watch as a restored tab, so opening one inside a
pinned folder left it in the auto group below. It is as new as a tab opened
here, and now joins the folder like one; every window that hears of it
reaches the same answer.

* test(machine): build the group sets in their initializers

Clippy's field_reassign_with_default on the two WorkspaceGroups the
set-groups test assembles.

* fix(sidebar): draw restored tabs in their auto group, and title by repo again

Auto groups are not stored, so after a restart every tab sat in Ungrouped
until its own repo probe came back, then jumped; before pinned groups the
stored repo key put it in place on the first frame. Each tab now carries
`last_auto`, the auto group it last resolved to, as a hint: stored with the
tab, sent up alongside its group in `TabSetGroup` whenever the live answer
moves, and used to draw the tab until the probe answers. The probe always wins
and rewrites the hint, and the hint never outranks a pinned group or the
folder-entry rule. Another window's hint only fills a gap, so two windows can
never bounce a disagreement between them.

The workspace's fallback title regained the repo majority it lost: the most
common pinned folder first, then the repo most unpinned tabs were last filed
under (a worktree counting toward its repo home), then a pane's cwd.

* refactor: drop what the new sidebar left unused, and two clippy findings

- `TerminalView::native_ssh_cwd` and its helper existed for the sidebar's old
  folder grouping of native SSH panes; an SSH tab now groups by host, and
  nothing else read it.
- The file tree's context menu takes `cx` instead of `danger` and the new
  groups flag, back to the argument count it had on main.
- A title test builds its workspace in the initializer.
This commit is contained in:
l0ng-ai
2026-09-25 16:53:26 +08:00
committed by GitHub
parent afcb8aa2dd
commit cf0e8f02e6
33 changed files with 3615 additions and 1115 deletions
+29
View File
@@ -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).
+1 -1
View File
@@ -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 · <kbd>⌃ R</kbd> fuzzy history |
| **Window** | tabs & splits · <kbd>⌘ P</kbd> palette · <kbd>⌘ F</kbd> scrollback search · <kbd>⌘ J</kbd> panel with process tree and listening ports · 13 themes, your own YAML, iTerm2 import · IME |
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></svg>

After

Width:  |  Height:  |  Size: 424 B

+77 -12
View File
@@ -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<String>,
@@ -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]
+11
View File
@@ -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() {
+8 -3
View File
@@ -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,
+14 -49
View File
@@ -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<String>,
/// 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]
+559 -105
View File
@@ -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<String>,
/// 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<String>,
#[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<String>) -> 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<AutoKeyWire> 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<AutoKey> 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<Self> {
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<GroupId> {
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<Self> {
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<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
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<PinnedGroup>,
/// 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<AutoKey>,
#[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<GroupId> {
pinned_folder_for(&self.pinned, cwd, repo_home)
}
}
impl<'de> serde::Deserialize<'de> for GroupKey {
fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
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<GroupId> {
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<PathBuf>>) -> Option<Option<AutoKey>> {
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<GroupId>,
groups: &WorkspaceGroups,
auto_grouping: bool,
auto: Option<AutoKey>,
) -> Option<GroupKey> {
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<Option<GroupId>>,
}
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<GroupId>) -> Option<GroupId> {
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<PinnedGroup> {
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"));
}
}
+130 -10
View File
@@ -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<Attachment>,
/// 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<String>,
#[serde(default)]
pub sidebar_group: Option<String>,
/// 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<GroupId>,
/// 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<AutoKey>,
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<String>,
group: Option<GroupId>,
#[serde(default)]
last_auto: Option<AutoKey>,
},
GroupsChanged {
groups: WorkspaceGroups,
},
TabRestructured {
tab: Tab,
@@ -802,19 +827,68 @@ impl MachineStore {
&self,
workspace: WorkspaceId,
tab: TabId,
group: Option<String>,
group: Option<GroupId>,
last_auto: Option<AutoKey>,
origin: Option<SubscriberId>,
) -> 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<SubscriberId>,
) -> 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<_>>(),
vec![
LayoutDelta::TabRegrouped {
tab: first.id,
group: None,
last_auto: None,
},
LayoutDelta::GroupsChanged { groups: kept },
]
);
}
+6 -1
View File
@@ -50,8 +50,13 @@ pub struct SessionTab {
#[serde(default)]
pub name: Option<String>,
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<crate::core::group_key::GroupKey>,
pub group: Option<crate::core::group_key::GroupId>,
/// 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<crate::core::group_key::AutoKey>,
#[serde(skip)]
pub tree_id: Option<crate::core::machine::TabId>,
/// Asleep in the tree: comes back as a placeholder holding `pane`, and
+16 -2
View File
@@ -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<String>,
group: Option<crate::core::group_key::GroupId>,
/// The auto group the tab last resolved to — see `Tab::last_auto`.
#[serde(default)]
last_auto: Option<crate::core::group_key::AutoKey>,
},
/// 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 { .. }
+7 -1
View File
@@ -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 {
@@ -95,7 +95,7 @@ impl Instance {
{{
"id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"name": null,
"sidebar_group": null,
"group": null,
"root": {{ "Leaf": {{ "pane": {pane_id} }} }}
}}
],
+6 -4
View File
@@ -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
+2 -2
View File
@@ -31,8 +31,8 @@ section something is in.
Keyboard shortcuts, Option as Meta, mouse behavior, selection, and clipboard.
</Card>
<Card title="Window & Tabs" icon="window-maximize">
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.
</Card>
<Card title="SSH" icon="server" href="/remote/ssh">
Hosts, connection defaults, security, authentication, proxies, and forwarding.
+1 -1
View File
@@ -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` | |
+52 -14
View File
@@ -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.
<img src="/images/sidebar.webp" alt="The tty7 tab sidebar" />
</Frame>
## 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 <kbd>⌘ T</kbd>
would), and <kbd>⌘ T</kbd> 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
-54
View File
@@ -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<std::path::PathBuf>,
) -> Option<std::path::PathBuf> {
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<std::path::PathBuf> {
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.
+106 -42
View File
@@ -405,7 +405,19 @@ pub struct Tab {
pub(crate) zoomed: Option<Entity<TerminalView>>,
pub(crate) diff_overlay: Option<crate::ui::diff_overlay::DiffOverlayState>,
pub(crate) code: Option<Box<crate::ui::code_editor::TabCode>>,
pub(crate) sidebar_group: std::cell::RefCell<Option<crate::core::group_key::GroupKey>>,
/// 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<Option<crate::core::group_key::GroupId>>,
/// 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<Option<crate::core::group_key::AutoKey>>,
/// 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<crate::core::group_key::EntryWatch>,
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<InputState>,
pub(crate) _subs: Vec<Subscription>,
}
@@ -962,12 +974,21 @@ pub struct Tty7App {
/// the sidebar — and takes no part in the reading.
pub(crate) strip_slots: Rc<RefCell<Vec<Bounds<Pixels>>>>,
pub(crate) sidebar_slots: Rc<RefCell<Vec<Bounds<Pixels>>>>,
/// 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<RefCell<Vec<(crate::core::group_key::GroupKey, Bounds<Pixels>)>>>,
Rc<RefCell<Vec<(crate::core::group_key::GroupId, Bounds<Pixels>)>>>,
/// 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<Cell<Option<Bounds<Pixels>>>>,
/// 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<Cell<Option<Bounds<Pixels>>>>,
@@ -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>,
) {
self.update_config(cx, |cfg| cfg.sidebar_grouping = grouping);
pub(crate) fn set_sidebar_auto_grouping(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.sidebar_auto_grouping = on);
}
pub(crate) fn set_sidebar_diff_preview(&mut self, on: bool, cx: &mut Context<Self>) {
@@ -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::<Config>().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::<Config>().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<std::path::PathBuf>) -> 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,
};
+1
View File
@@ -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"),
+22 -3
View File
@@ -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<Self>,
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::<crate::core::config::Config>().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(
+14 -7
View File
@@ -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),
+17 -8
View File
@@ -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}",
+17 -8
View File
@@ -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}",
+14 -5
View File
@@ -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,
+17 -8
View File
@@ -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}",
+116 -30
View File
@@ -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<String> {
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<String> {
.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<Item = String>) -> Option<String> {
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<String> {
@@ -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");
}
}
+6
View File
@@ -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
+117 -13
View File
@@ -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<RefCell<Option<Reorder>>>;
@@ -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<Vec<usize>>,
/// The surface `order` is an order of.
pub(crate) surface: Option<Surface>,
/// 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<AutoKey>,
}
/// 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<TabId> {
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<AutoKey> {
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<GroupKey>),
/// 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<usize>,
generation: Cell<usize>,
pending: RefCell<Option<Vec<usize>>>,
/// The custom group this drag is being held over, when the pointer has
/// left the group the tab came from.
regroup: RefCell<Option<GroupKey>>,
/// The group this drag is being held over, when the pointer has left the
/// group the tab came from.
regroup: RefCell<Option<Regroup>>,
/// The auto group whose header this drag picked up, for the one gesture
/// that pins: carrying it above the divider.
auto: Option<AutoKey>,
/// Set on the frames the carried auto header is above the divider.
pin: Cell<bool>,
/// 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);
+13 -27
View File
@@ -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",
+2023 -635
View File
File diff suppressed because it is too large Load Diff
+17 -30
View File
@@ -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::<Config>().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::<Config>().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);
+194 -39
View File
@@ -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<String>,
pub group: Option<String>,
pub group: Option<GroupId>,
pub last_auto: Option<AutoKey>,
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<Pan
pub(crate) struct WsMirror {
pub tabs: Vec<TreeTab>,
pub active: Option<TabId>,
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<ChosenName>,
/// 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<WorkspaceGroups>,
/// 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<String>, 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::<crate::core::session::WorkspaceStore>() {
return;
}
let machine_ws = tree_workspace_id(cx, client_ws);
let state = cx
.default_global::<TreeSync>()
.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::<TreeSync>().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::<TreeSync>()
.windows
.get(&client_ws)
.map(|s| &s.sync)
{
Some(SyncPhase::Primed(mirror)) => mirror.groups.clone(),
_ => return,
};
if !cx.has_global::<crate::ui::windows::WindowRegistry>() {
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<TreeTab>| SyncPhase::Primed(WsMirror { tabs, active: None });
let primed_with = |tabs: Vec<TreeTab>| {
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,
},