mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-10-03 08:02:02 +00:00
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:
@@ -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).
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
@@ -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]
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 { .. }
|
||||
|
||||
@@ -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} }} }}
|
||||
}}
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+17
-30
@@ -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
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user