feat(sidebar): group a tab by its folder when its cwd is not a repo (#631)

`sidebar_grouping` gains a third, opt-in mode, `repo-or-directory`: group by repository home as before, and when the repo probe has landed and answered "not a repo", group under the cwd itself instead of filing every such tab under Scratch. A probe that has not run yet resolves to no decision, so a tab keeps the group it already has rather than bouncing through Scratch mid-probe. The decision lives in one `resolved_group` free function shared by the per-frame key derivation and spawn-time seeding.

The default (`repo`) and flat modes behave exactly as before, and an unknown value in an existing config still degrades to `repo`.

Knock-on: `machine_mirror::subject_path_of` names a window after its most common group, so in the new mode a window of plain shells takes its name from the most common directory rather than from the first pane's cwd.

Closes #620.
This commit is contained in:
webdev
2026-08-14 15:57:36 +08:00
committed by GitHub
parent 0346e35b40
commit 422808191d
9 changed files with 115 additions and 27 deletions
+24
View File
@@ -419,6 +419,9 @@ pub enum WindowBackdrop {
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,
}
@@ -1201,6 +1204,27 @@ mod tests {
assert!(!back.sidebar_diff_preview);
}
#[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);
}
#[test]
fn dim_inactive_panes_defaults_on_and_round_trips() {
assert!(Config::default().dim_inactive_panes);
+1 -1
View File
@@ -69,7 +69,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`. |
| `sidebar_grouping` | enum | `"repo"` | Or `none` for a flat list. |
| `sidebar_grouping` | enum | `"repo"` | Or `repo-or-directory` to group non-repo tabs by their folder, or `none` for a flat list. |
| `sidebar_diff_preview` | bool | `true` | Clicking a row's `+N M` opens the diff overlay. |
| `sidebar_width` | number | `220` | Pixels (1002000). |
| `sidebar_collapsed` | bool | `false` | |
+6 -4
View File
@@ -22,7 +22,10 @@ not its history — switching branches or `cd`-ing around inside a repository
never moves a row out from under its header.
**Settings → Window & Tabs → Sidebar grouping** switches between *By repo* (the
default) and *Flat*.
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.
## What a row tells you
@@ -52,9 +55,8 @@ 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: with the default repo
grouping a tab's group comes from its working directory, so `cd` is what moves
it.
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.
## Naming
+3 -2
View File
@@ -547,7 +547,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
}
L10nKey::SettingsSidebarGrouping => "Sidebar grouping",
L10nKey::SettingsSidebarGroupingDesc => {
"Group sidebar tabs under a header per git repository, with non-repo tabs in a Scratch section. Only applies to the left sidebar."
"Group sidebar tabs under a header per git repository. Non-repo tabs collect in a Scratch section, or under their working directory with \"By repo or folder\". Only applies to the left sidebar."
}
L10nKey::SettingsDiffPreviewFromCounts => "Open diff preview from sidebar counts",
L10nKey::SettingsDiffPreviewFromCountsDesc => {
@@ -574,6 +574,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SettingsTop => "Top",
L10nKey::SettingsLeft => "Left",
L10nKey::SettingsByRepo => "By repo",
L10nKey::SettingsByRepoOrFolder => "By repo or folder",
L10nKey::SettingsFlat => "Flat",
L10nKey::SettingsPreset => "Preset",
L10nKey::SettingsPresetDesc => {
@@ -834,7 +835,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"
"tabs group repo repository git scratch header sidebar flat folder directory cwd"
}
L10nKey::SettingsSearchSmartSelectionKeywords => {
"double click word url path select semantic bracket email"
+3 -2
View File
@@ -554,7 +554,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
}
L10nKey::SettingsSidebarGrouping => "サイドバーのグループ化",
L10nKey::SettingsSidebarGroupingDesc => {
"git リポジトリごとにサイドバータブをまとめリポジトリ外のタブはスクラッチセクションに置ます。左サイドバーにのみ適用"
"git リポジトリごとにサイドバータブをまとめます。リポジトリ外のタブはスクラッチセクションに置くか、「リポジトリ/フォルダ別」なら作業ディレクトリごとにまとめます。左サイドバーにのみ適用"
}
L10nKey::SettingsDiffPreviewFromCounts => "サイドバーのカウントから Diff プレビューを開く",
L10nKey::SettingsDiffPreviewFromCountsDesc => {
@@ -579,6 +579,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsTop => "上部",
L10nKey::SettingsLeft => "左側",
L10nKey::SettingsByRepo => "リポジトリ別",
L10nKey::SettingsByRepoOrFolder => "リポジトリ/フォルダ別",
L10nKey::SettingsFlat => "フラット表示",
L10nKey::SettingsPreset => "プリセット",
L10nKey::SettingsPresetDesc => {
@@ -880,7 +881,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"
"タブ グループ リポジトリ git スクラッチ ヘッダー サイドバー フラット フォルダ ディレクトリ sidebar grouping tabs repo repository git scratch header flat folder directory"
}
L10nKey::SettingsSearchSmartSelectionKeywords => {
"ダブルクリック 単語 url パス 選択 セマンティック 括弧 メール smart selection double click word url path bracket email"
+1
View File
@@ -478,6 +478,7 @@ l10n_keys! {
SettingsTop,
SettingsLeft,
SettingsByRepo,
SettingsByRepoOrFolder,
SettingsFlat,
SettingsPreset,
SettingsPresetDesc,
+3 -2
View File
@@ -480,7 +480,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsTabBarPositionDesc => "将标签页显示为顶部横向条或左侧垂直侧栏。",
L10nKey::SettingsSidebarGrouping => "侧栏分组",
L10nKey::SettingsSidebarGroupingDesc => {
"按 git 仓库在标题下对侧栏标签页分组非仓库标签页放在“草稿”分组。仅适用于左侧栏。"
"按 git 仓库在标题下对侧栏标签页分组非仓库标签页放在“草稿”分组,选“按仓库或文件夹”时则按其工作目录分组。仅适用于左侧栏。"
}
L10nKey::SettingsDiffPreviewFromCounts => "从侧栏计数打开 diff 预览",
L10nKey::SettingsDiffPreviewFromCountsDesc => {
@@ -503,6 +503,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsTop => "顶部",
L10nKey::SettingsLeft => "左侧",
L10nKey::SettingsByRepo => "按仓库",
L10nKey::SettingsByRepoOrFolder => "按仓库或文件夹",
L10nKey::SettingsFlat => "平铺",
L10nKey::SettingsPreset => "预设",
L10nKey::SettingsPresetDesc => {
@@ -786,7 +787,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
"显示托盘图标 托盘 菜单栏 状态 图标 show tray icon menu bar status"
}
L10nKey::SettingsSearchSidebarGroupingKeywords => {
"侧栏分组 标签页 分组 仓库 git 侧栏 sidebar grouping tabs repo repository"
"侧栏分组 标签页 分组 仓库 git 侧栏 文件夹 目录 sidebar grouping tabs repo repository folder directory"
}
L10nKey::SettingsSearchSmartSelectionKeywords => {
"智能选择 双击 选择 单词 URL 路径 邮箱 括号 smart selection double click"
+11 -6
View File
@@ -5774,7 +5774,8 @@ impl Tty7App {
let sidebar_diff_preview = cfg.sidebar_diff_preview;
let sidebar_grouping_idx = match cfg.sidebar_grouping {
crate::core::config::SidebarGrouping::Repo => 0,
crate::core::config::SidebarGrouping::None => 1,
crate::core::config::SidebarGrouping::RepoOrDirectory => 1,
crate::core::config::SidebarGrouping::None => 2,
};
let notify_idx = match cfg.notify_on_command_finish {
NotifyMode::Never => 0,
@@ -5885,14 +5886,18 @@ impl Tty7App {
.into_any_element();
let sidebar_grouping_radio = self.segmented(
"wt-sidebar-grouping",
&[t(L10nKey::SettingsByRepo), t(L10nKey::SettingsFlat)],
&[
t(L10nKey::SettingsByRepo),
t(L10nKey::SettingsByRepoOrFolder),
t(L10nKey::SettingsFlat),
],
sidebar_grouping_idx,
cx,
|this, ix, _w, cx| {
let grouping = if ix == 0 {
crate::core::config::SidebarGrouping::Repo
} else {
crate::core::config::SidebarGrouping::None
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);
},
+63 -10
View File
@@ -1176,21 +1176,22 @@ impl Tty7App {
}
fn sidebar_group_keys(&self, cx: &gpui::App) -> Vec<Option<PathBuf>> {
let grouping = cx.global::<Config>().sidebar_grouping == SidebarGrouping::Repo;
let grouping = cx.global::<Config>().sidebar_grouping;
self.tabs
.iter()
.map(|tab| {
if !grouping {
if grouping == SidebarGrouping::None {
return None;
}
let cwd = tab.pane.first_leaf().and_then(|leaf| {
let view = leaf.terminal()?.read(cx);
Some((view.host_id(), view.git_status_cwd()?.to_path_buf()))
});
if let Some(known) =
cwd.and_then(|(id, cwd)| cx.global::<GitStatusCache>().known_repo_for(id, &cwd))
{
*tab.sidebar_group.borrow_mut() = known;
if let Some((id, cwd)) = cwd {
let known = cx.global::<GitStatusCache>().known_repo_for(id, &cwd);
if let Some(group) = resolved_group(grouping, known, &cwd) {
*tab.sidebar_group.borrow_mut() = group;
}
}
tab.sidebar_group.borrow().clone()
})
@@ -1220,8 +1221,10 @@ impl Tty7App {
}
/// Which group a tab about to be spawned in `cwd` belongs to, when the
/// repo probe for that directory has already landed. `Some(None)` means
/// the cache knows it is not a repo; a bare `None` means it never looked.
/// repo probe for that directory has already landed. A bare `None` means
/// the cache never looked; `Some` is the group [`resolved_group`] reached
/// — the repo home, the cwd itself under repo-or-directory grouping, or
/// `Some(None)` for Scratch.
///
/// A tab's group otherwise starts empty and only fills in once its shell
/// has started and reported a cwd, which parks every new tab in the
@@ -1233,15 +1236,34 @@ impl Tty7App {
cwd: Option<&Path>,
cx: &gpui::App,
) -> Option<Option<PathBuf>> {
let cwd = cwd?;
let host = self
.window_workspace(cx)
.as_ref()
.map_or(crate::ui::host_ops::HostId::LOCAL, |ws| ws.target.host_id());
cx.try_global::<GitStatusCache>()?
.known_repo_for(host, cwd?)
let known = cx.try_global::<GitStatusCache>()?.known_repo_for(host, cwd);
resolved_group(cx.global::<Config>().sidebar_grouping, known, cwd)
}
}
/// The group a probed cwd resolves to under `grouping`: the repo home when
/// the cache found one, otherwise Scratch — or the cwd itself under
/// repo-or-directory grouping, so a shell in a plain folder still gets a
/// header. `known` is the cache's three-valued answer; a probe that never
/// ran resolves to `None`, no decision, and the tab keeps whatever group it
/// already has rather than bouncing through Scratch mid-probe.
fn resolved_group(
grouping: SidebarGrouping,
known: Option<Option<PathBuf>>,
cwd: &Path,
) -> Option<Option<PathBuf>> {
Some(match known? {
Some(root) => Some(root),
None if grouping == SidebarGrouping::RepoOrDirectory => Some(cwd.to_path_buf()),
None => None,
})
}
#[derive(Debug, PartialEq)]
struct Section {
key: Option<PathBuf>,
@@ -1422,6 +1444,37 @@ mod tests {
assert_eq!(diff_click_cwd::<PathBuf>(&cfg, None), None);
}
#[test]
fn a_probed_non_repo_groups_by_folder_only_in_the_fallback_mode() {
let cwd = p("/w/plain");
assert_eq!(
resolved_group(SidebarGrouping::RepoOrDirectory, Some(None), &cwd),
Some(Some(p("/w/plain")))
);
assert_eq!(
resolved_group(SidebarGrouping::Repo, Some(None), &cwd),
Some(None),
"under Repo a probed non-repo still falls to Scratch"
);
// Never probed: no decision in either mode, so the tab keeps the
// group it already has instead of bouncing through Scratch.
assert_eq!(resolved_group(SidebarGrouping::Repo, None, &cwd), None);
assert_eq!(
resolved_group(SidebarGrouping::RepoOrDirectory, None, &cwd),
None
);
}
#[test]
fn a_known_repo_home_wins_over_the_folder_in_both_modes() {
for mode in [SidebarGrouping::Repo, SidebarGrouping::RepoOrDirectory] {
assert_eq!(
resolved_group(mode, Some(Some(p("/w/repo"))), &p("/w/repo/sub")),
Some(Some(p("/w/repo")))
);
}
}
#[test]
fn sections_order_groups_by_first_appearance_scratch_last() {
let keys = vec![