diff --git a/CHANGELOG.md b/CHANGELOG.md
index f77640ff..09ae4e40 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,23 @@ All notable changes to tty7 are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [Unreleased]
+
+### Added
+
+- **The source control panel's changed files can be filtered and shown as a
+ tree** (#473). A filter field sits above the list: every word typed has to
+ appear somewhere in a file's path, in any order and any case, and the group
+ headers' counts and their stage/unstage/discard-all buttons follow what the
+ filter leaves on screen. The tile at its end switches the list to a
+ directory tree — directories before files, a chain of directories that hold
+ nothing but the next one compacted into a single row (`crates/core/src`),
+ each directory row foldable and counting the files beneath it. A filter
+ opens every folded directory while it is set. The choice between list and
+ tree is remembered in the config. Both are built from the status the host
+ already sends, so a remote repository gets them unchanged. Searching the
+ diff text itself is not part of this.
+
## [26.9.3] - 2026-09-23
### Added
diff --git a/assets/icons/list-flat.svg b/assets/icons/list-flat.svg
new file mode 100644
index 00000000..b6249b13
--- /dev/null
+++ b/assets/icons/list-flat.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/list-tree.svg b/assets/icons/list-tree.svg
new file mode 100644
index 00000000..04086a1a
--- /dev/null
+++ b/assets/icons/list-tree.svg
@@ -0,0 +1 @@
+
diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs
index 01fb7bfc..07bc28b0 100644
--- a/crates/tty7-core/src/core/config.rs
+++ b/crates/tty7-core/src/core/config.rs
@@ -246,6 +246,12 @@ pub struct Config {
/// impression than one they asked for.
#[serde(default)]
pub scm_graph_expanded: bool,
+ /// Whether the source control panel lists changed files as a directory
+ /// tree rather than one flat run per group. Off by default: the flat list
+ /// is what a handful of changes reads best as, and the tree is for the
+ /// agent run that touched sixty files across a dozen modules.
+ #[serde(default)]
+ pub scm_changes_tree: bool,
/// Whether a file opens in the code panel with soft wrap on. Not a
/// setting anyone picks up front: it is whatever the status bar's Wrap
/// toggle (or `ToggleDocumentWrap`) was last left at, so the next file
@@ -676,6 +682,7 @@ impl Default for Config {
document_layout: DocumentLayout::default(),
document_ratio: default_document_ratio(),
scm_graph_expanded: false,
+ scm_changes_tree: false,
editor_soft_wrap: false,
editor_markdown_preview: false,
sidebar_grouping: SidebarGrouping::Repo,
diff --git a/src/ui/assets.rs b/src/ui/assets.rs
index 2bf9e9ab..a35f0329 100644
--- a/src/ui/assets.rs
+++ b/src/ui/assets.rs
@@ -47,6 +47,8 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> {
"icons/machine-local.svg" => include_bytes!("../../assets/icons/machine-local.svg"),
"icons/machine-remote.svg" => include_bytes!("../../assets/icons/machine-remote.svg"),
"icons/refresh.svg" => include_bytes!("../../assets/icons/refresh.svg"),
+ "icons/list-tree.svg" => include_bytes!("../../assets/icons/list-tree.svg"),
+ "icons/list-flat.svg" => include_bytes!("../../assets/icons/list-flat.svg"),
"icons/agents/claude.svg" => include_bytes!("../../assets/icons/agents/claude.svg"),
"icons/agents/codex.svg" => include_bytes!("../../assets/icons/agents/codex.svg"),
"icons/agents/traecli.svg" => include_bytes!("../../assets/icons/agents/traecli.svg"),
@@ -114,6 +116,8 @@ mod tests {
"icons/git-branch.svg",
"icons/git-sync.svg",
"icons/git-commit.svg",
+ "icons/list-tree.svg",
+ "icons/list-flat.svg",
] {
assert!(
Assets.load(path).unwrap().is_some(),
diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs
index d0e8621a..6013f4e6 100644
--- a/src/ui/i18n/en.rs
+++ b/src/ui/i18n/en.rs
@@ -1216,6 +1216,10 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::ScmShowMore => "Show more",
L10nKey::ScmShowLess => "Show less",
L10nKey::ScmCommitNotFound => "This commit is not in this repository.",
+ L10nKey::ScmFilterChanges => "Filter changed files…",
+ L10nKey::ScmViewAsTree => "View as Tree",
+ L10nKey::ScmViewAsList => "View as List",
+ L10nKey::ScmNoMatchingChanges => "No changed files match the filter.",
L10nKey::ScmTooManyChanges => "Showing the first {shown} of {total} changes.",
L10nKey::ScmOpenChanges => "Open Changes",
L10nKey::ScmDiscardAllConfirm => {
diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs
index 56470abf..028e45a5 100644
--- a/src/ui/i18n/ja.rs
+++ b/src/ui/i18n/ja.rs
@@ -1290,6 +1290,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::ScmShowMore => "続きを表示",
L10nKey::ScmShowLess => "折りたたむ",
L10nKey::ScmCommitNotFound => "このリポジトリにそのコミットはありません。",
+ L10nKey::ScmFilterChanges => "変更ファイルを絞り込む…",
+ L10nKey::ScmViewAsTree => "ツリーで表示",
+ L10nKey::ScmViewAsList => "リストで表示",
+ L10nKey::ScmNoMatchingChanges => "条件に一致する変更ファイルはありません。",
L10nKey::ScmTooManyChanges => {
"変更が多いため、{total} 件のうち先頭 {shown} 件のみ表示しています。"
}
diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs
index c3b97bb1..b8b60db2 100644
--- a/src/ui/i18n/mod.rs
+++ b/src/ui/i18n/mod.rs
@@ -913,6 +913,10 @@ l10n_keys! {
ScmShowLess,
ScmCommitNotFound,
ScmTooManyChanges,
+ ScmFilterChanges,
+ ScmViewAsTree,
+ ScmViewAsList,
+ ScmNoMatchingChanges,
ScmOpenChanges,
ScmDiscardAllConfirm,
ScmAmendConfirm,
diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs
index 82d96fb2..f9c59eb0 100644
--- a/src/ui/i18n/zh.rs
+++ b/src/ui/i18n/zh.rs
@@ -1156,6 +1156,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::ScmShowMore => "展开",
L10nKey::ScmShowLess => "收起",
L10nKey::ScmCommitNotFound => "本仓库中没有这个提交。",
+ L10nKey::ScmFilterChanges => "筛选变更文件…",
+ L10nKey::ScmViewAsTree => "以树形显示",
+ L10nKey::ScmViewAsList => "以列表显示",
+ L10nKey::ScmNoMatchingChanges => "没有匹配筛选的变更文件。",
L10nKey::ScmTooManyChanges => "改动过多,仅显示前 {shown} 项(共 {total} 项)。",
L10nKey::ScmOpenChanges => "查看改动",
L10nKey::ScmDiscardAllConfirm => {
diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs
index 994f83c0..d96dbb32 100644
--- a/src/ui/right_panel.rs
+++ b/src/ui/right_panel.rs
@@ -729,6 +729,18 @@ impl Tty7App {
input: &gpui::Entity,
cx: &mut Context,
) -> AnyElement {
+ self.panel_search_with(input, None, cx)
+ }
+
+ /// [`Self::panel_search`] with a tile at its far end, inset the way the
+ /// branch row's tile is so the two stack in one column.
+ pub(crate) fn panel_search_with(
+ &self,
+ input: &gpui::Entity,
+ trailing: Option,
+ cx: &mut Context,
+ ) -> AnyElement {
+ let has_trailing = trailing.is_some();
h_flex()
.flex_none()
.items_center()
@@ -753,6 +765,8 @@ impl Tty7App {
// still reads as one line of chrome.
.child(Input::new(input).appearance(false).xsmall().cleanable(true)),
)
+ .when(has_trailing, |row| row.pr(px(tile_trailing_inset_sm())))
+ .children(trailing)
.into_any_element()
}
diff --git a/src/ui/scm/actions.rs b/src/ui/scm/actions.rs
index 3d5ea2e1..bdede8b6 100644
--- a/src/ui/scm/actions.rs
+++ b/src/ui/scm/actions.rs
@@ -71,6 +71,16 @@ impl Tty7App {
cx.notify();
}
+ /// Switch the changed-file list between flat and tree, and remember it.
+ ///
+ /// Global like `diff_view`: someone who reads changes by directory reads
+ /// every repository's that way.
+ pub(crate) fn scm_toggle_changes_tree(&mut self, cx: &mut Context) {
+ let next = !cx.global::().scm_changes_tree;
+ self.update_config(cx, |cfg| cfg.scm_changes_tree = next);
+ cx.notify();
+ }
+
/// Flip the diff overlay between side-by-side and unified.
///
/// Global rather than per-overlay, matching `diffEditor.renderSideBySide`:
diff --git a/src/ui/scm/mod.rs b/src/ui/scm/mod.rs
index 50f4f4e9..39529ce4 100644
--- a/src/ui/scm/mod.rs
+++ b/src/ui/scm/mod.rs
@@ -14,6 +14,7 @@ pub(crate) mod path;
pub(crate) mod state;
#[allow(dead_code)]
pub(crate) mod status;
+pub(crate) mod tree;
pub(crate) use actions::ScmIntent;
pub(crate) use state::{GraphState, ScmPanelState};
diff --git a/src/ui/scm/panel.rs b/src/ui/scm/panel.rs
index 63013637..1bd1c430 100644
--- a/src/ui/scm/panel.rs
+++ b/src/ui/scm/panel.rs
@@ -39,6 +39,7 @@ use crate::ui::scm::ScmIntent;
use crate::ui::scm::path::split_display_path;
use crate::ui::scm::state::{RepoKey, ScmGroup};
use crate::ui::scm::status::{status_color, status_glyph};
+use crate::ui::scm::tree::{TreeRow, normalize_query, path_matches, tree_rows};
/// Shared compact pitch for working-tree and commit-detail file rows.
pub(super) const ROW_H: f32 = 26.;
@@ -52,6 +53,10 @@ pub(super) const ROW_H: f32 = 26.;
/// different numbers.
const BADGE_W: f32 = 14.;
+/// One level of the tree view, the file tree's step: the Files panel and this
+/// one draw the same directories, and the same depth should read the same.
+const TREE_INDENT: f32 = 14.;
+
/// The key context the message box installs, and the one `ScmCommit` is
/// bound inside. The two are the same string on purpose: a binding whose
/// context nothing attaches is a binding that never fires.
@@ -236,7 +241,12 @@ impl Tty7App {
Some(t(L10nKey::PanelNoChangesHint)),
cx,
),
- None => self.scm_groups(&repo, &status, cx),
+ None => {
+ // Pinned with the rest, so the filter stays in reach however
+ // far down the list has been scrolled.
+ pinned.push(self.scm_filter_row(window, cx));
+ self.scm_groups(&repo, &status, cx)
+ }
};
let history = self.render_graph_section(&repo, window, cx);
self.scm_shell_full(title, pinned, body, history)
@@ -1291,24 +1301,63 @@ impl Tty7App {
status: &Arc,
cx: &mut Context,
) -> AnyElement {
+ let query = self.scm_filter_query(cx);
+ let tree = cx.global::().scm_changes_tree;
+ let mut matched = 0;
let mut list = v_flex().px(px(CONTENT_INSET - ROW_INSET)).py(px(2.));
for group in ScmGroup::ORDER {
+ // Filtered before anything else reads it, so the header's count
+ // and its stage/discard-all act on what is on screen — a
+ // "discard all" that also reached the files the filter hid would
+ // be the one surprise this panel cannot take back.
let entries: Vec<&StatusEntry> = status
.entries
.iter()
.filter(|e| in_group(e, group))
+ .filter(|e| {
+ query
+ .as_deref()
+ .is_none_or(|q| path_matches(e.path.as_str(), q))
+ })
.collect();
if entries.is_empty() {
continue;
}
+ matched += entries.len();
let collapsed = self.scm.group_collapsed(group, entries.len());
list = list.child(self.scm_group_header(repo, group, &entries, collapsed, cx));
if collapsed {
continue;
}
let shown = entries.len().min(MAX_RENDERED_FILES);
- for entry in entries.iter().take(shown) {
- list = list.child(self.scm_file_row(repo, group, entry, cx));
+ if tree {
+ // A filter opens every directory: a match folded away under
+ // a closed row is a match the filter failed to show.
+ let filtering = query.is_some();
+ let folded = &self.scm.folded_dirs;
+ let rows = tree_rows(entries[..shown].iter().map(|e| e.path.as_str()), |key| {
+ !filtering && folded.contains(&(group, key.to_string()))
+ });
+ for row in rows {
+ list = list.child(match row {
+ TreeRow::Dir {
+ key,
+ label,
+ depth,
+ files,
+ collapsed,
+ } => self.scm_dir_row(
+ group, key, label, depth, files, collapsed, !filtering, cx,
+ ),
+ TreeRow::File { index, depth } => {
+ self.scm_file_row(repo, group, entries[index], Some(depth), cx)
+ }
+ });
+ }
+ } else {
+ for entry in entries.iter().take(shown) {
+ list = list.child(self.scm_file_row(repo, group, entry, None, cx));
+ }
}
if entries.len() > shown {
list = list.child(self.scm_note(
@@ -1317,6 +1366,9 @@ impl Tty7App {
));
}
}
+ if query.is_some() && matched == 0 {
+ list = list.child(self.scm_note(t(L10nKey::ScmNoMatchingChanges).to_string(), cx));
+ }
if status.truncated {
list = list.child(self.scm_note(
t_fmt(
@@ -1332,6 +1384,142 @@ impl Tty7App {
list.into_any_element()
}
+ /// The filter over the changed files, with the list/tree switch at its
+ /// end.
+ ///
+ /// A standing field, like the Files panel's, rather than one behind a
+ /// tile like the history's: the list is what this panel is for, and a
+ /// run that touched sixty files is exactly when nobody wants to go
+ /// looking for how to narrow it.
+ fn scm_filter_row(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement {
+ let input = match self.scm.filter.clone() {
+ Some(input) => input,
+ None => {
+ let input = cx.new(|cx| {
+ InputState::new(window, cx).placeholder(t(L10nKey::ScmFilterChanges))
+ });
+ // An `InputState` is its own entity; without this the list
+ // would never hear about the text typed into it.
+ self.scm.filter_sub =
+ Some(
+ cx.subscribe_in(&input, window, |_this, _input, ev, _window, cx| {
+ if matches!(ev, gpui_component::input::InputEvent::Change) {
+ cx.notify();
+ }
+ }),
+ );
+ self.scm.filter = Some(input.clone());
+ input
+ }
+ };
+ let tree = cx.global::().scm_changes_tree;
+ // The tile shows where it goes, not where you are — the rows under it
+ // already say which view this is.
+ let (glyph, tip) = match tree {
+ true => ("icons/list-flat.svg", t(L10nKey::ScmViewAsList)),
+ false => ("icons/list-tree.svg", t(L10nKey::ScmViewAsTree)),
+ };
+ let toggle = crate::ui::tab_strip::chrome_tile_sized(
+ Button::new("scm-changes-view").icon(Icon::empty().path(glyph)),
+ crate::ui::app::TILE_SIZE_SM,
+ crate::ui::app::TILE_GLYPH_SM,
+ false,
+ cx,
+ )
+ .rounded_md()
+ .tooltip(tip)
+ .on_click(cx.listener(|this, _, _window, cx| this.scm_toggle_changes_tree(cx)))
+ .into_any_element();
+ self.panel_search_with(&input, Some(toggle), cx)
+ }
+
+ /// The filter box's text, if it has any.
+ fn scm_filter_query(&self, cx: &gpui::App) -> Option {
+ normalize_query(&self.scm.filter.as_ref()?.read(cx).value())
+ }
+
+ /// A directory in the tree view: a chevron in the status letters'
+ /// column, the (possibly compacted) name, and how many changed files
+ /// are under it.
+ #[allow(clippy::too_many_arguments)]
+ fn scm_dir_row(
+ &self,
+ group: ScmGroup,
+ key: String,
+ label: String,
+ depth: usize,
+ files: usize,
+ collapsed: bool,
+ foldable: bool,
+ cx: &mut Context,
+ ) -> AnyElement {
+ let sf = cx.global::().sidebar;
+ let mono = cx.theme().mono_font_family.clone();
+ let muted = cx.theme().muted_foreground;
+ let id = SharedString::from(format!("scm-dir-{group:?}-{key}"));
+ let selector = id.clone();
+ let toggle = cx.listener({
+ let key = key.clone();
+ move |this, _, _window, cx| {
+ this.scm.toggle_dir(group, key.clone());
+ cx.notify();
+ }
+ });
+ h_flex()
+ .id(id)
+ .debug_selector(move || selector.to_string())
+ .items_center()
+ .gap(px(8.))
+ .min_h(rems(ROW_H / 16.))
+ .w_full()
+ .min_w_0()
+ .px(px(ROW_INSET))
+ .pl(px(ROW_INSET + depth as f32 * TREE_INDENT))
+ .rounded(px(5.))
+ .when(foldable, |row| {
+ row.cursor_pointer()
+ .hover(|s| s.bg(gpui::rgb(sf.hover)))
+ .on_click(toggle)
+ })
+ .tooltip(move |window, cx| {
+ gpui_component::tooltip::Tooltip::new(key.clone()).build(window, cx)
+ })
+ .child(
+ div()
+ .flex_none()
+ .w(px(BADGE_W))
+ .flex()
+ .justify_center()
+ .text_color(muted)
+ .child(
+ Icon::new(if collapsed {
+ IconName::ChevronRight
+ } else {
+ IconName::ChevronDown
+ })
+ .xsmall(),
+ ),
+ )
+ .child(
+ div()
+ .flex_1()
+ .min_w_0()
+ .truncate()
+ .text_size(rems(crate::ui::right_panel::TEXT))
+ .text_color(gpui::rgb(sf.text_resting))
+ .child(label),
+ )
+ .child(
+ div()
+ .flex_none()
+ .text_size(rems(META_MONO))
+ .font_family(mono)
+ .text_color(muted)
+ .child(files.to_string()),
+ )
+ .into_any_element()
+ }
+
/// "…and 40 more", and the note about a status git had to truncate.
///
/// Secondary: it is prose about the list rather than a row of it, and the
@@ -1413,11 +1601,15 @@ impl Tty7App {
.into_any_element()
}
+ /// One changed file. `depth` is its level in the tree view, and `None` in
+ /// the flat list — where the row carries its directory beside the name,
+ /// since there is no directory row above it to say it.
fn scm_file_row(
&self,
repo: &RepoKey,
group: ScmGroup,
entry: &StatusEntry,
+ depth: Option,
cx: &mut Context,
) -> AnyElement {
let sf = cx.global::().sidebar;
@@ -1438,8 +1630,10 @@ impl Tty7App {
cx,
);
+ let selector = id.clone();
h_flex()
.id(id.clone())
+ .debug_selector(move || selector.to_string())
.group(id)
.relative()
.items_center()
@@ -1448,6 +1642,9 @@ impl Tty7App {
.w_full()
.min_w_0()
.px(px(ROW_INSET))
+ .when_some(depth, |row, depth| {
+ row.pl(px(ROW_INSET + depth as f32 * TREE_INDENT))
+ })
.py(px(3.))
.rounded(px(5.))
.cursor_pointer()
@@ -1503,7 +1700,7 @@ impl Tty7App {
// Cap the secondary directory while the filename fills the rest.
// A rem cap also gives short paths their intrinsic width inside
// the context-menu wrapper's flex layout.
- .when(!dir.is_empty(), |this| {
+ .when(!dir.is_empty() && depth.is_none(), |this| {
this.child(
div()
.flex_none()
@@ -3012,6 +3209,80 @@ mod render_idle_gpui_tests {
assert_eq!(draws_while_idle(&mut vcx), 0);
let _ = std::fs::remove_dir_all(&root);
}
+
+ /// The tree view and the filter, end to end over a real repository: the
+ /// rows that come out are the ones the pure helpers promise, drawn.
+ #[gpui::test]
+ fn the_changes_list_folds_into_a_tree_and_filters_by_path(cx: &mut TestAppContext) {
+ let _serial = serial();
+ crate::core::config::pin_test_config_dir();
+ let root = scratch("tree");
+ git(&root, &["init", "--quiet"]);
+ std::fs::create_dir_all(root.join("src/ui")).unwrap();
+ std::fs::create_dir_all(root.join("docs")).unwrap();
+ std::fs::write(root.join("src/ui/a.rs"), "a\n").unwrap();
+ std::fs::write(root.join("src/ui/b.rs"), "b\n").unwrap();
+ std::fs::write(root.join("docs/readme.md"), "r\n").unwrap();
+
+ // Keyed off the root the panel resolved, not off `root`: on Windows
+ // `scratch` hands back a `\\?\` verbatim path that never compares
+ // equal to git's spelling of the same directory — see the note on
+ // `a_settled_source_control_panel_reaches_render_idle`.
+ let (app, mut vcx, _pane) = scm_panel_on(cx, &root, |app, cx| {
+ app.scm.repo.as_ref().is_some_and(|r| {
+ crate::terminal::git_data::status_of(cx, r.host, &r.root)
+ .is_some_and(|s| s.untracked().count() == 3)
+ })
+ });
+ let drawn = |vcx: &mut VisualTestContext, what: &'static str| {
+ app.update_in(vcx, |_, _, cx| cx.notify());
+ vcx.run_until_parked();
+ vcx.debug_bounds(what).is_some()
+ };
+ let a = "scm-row-Untracked-src/ui/a.rs";
+ let readme = "scm-row-Untracked-docs/readme.md";
+ let src_ui = "scm-dir-Untracked-src/ui";
+
+ // The flat list is the default, and it has no directory rows.
+ assert!(drawn(&mut vcx, a));
+ assert!(!drawn(&mut vcx, src_ui));
+
+ // `src` holds nothing but `ui`, so the two share one row.
+ app.update(&mut vcx, |app, cx| app.scm_toggle_changes_tree(cx));
+ assert!(vcx.update(|_, cx| cx.global::().scm_changes_tree));
+ assert!(drawn(&mut vcx, src_ui));
+ assert!(!drawn(&mut vcx, "scm-dir-Untracked-src"));
+ assert!(drawn(&mut vcx, "scm-dir-Untracked-docs"));
+ assert!(drawn(&mut vcx, a));
+
+ // Folding the directory keeps its row and hides what is under it.
+ app.update(&mut vcx, |app, cx| {
+ app.scm.toggle_dir(ScmGroup::Untracked, "src/ui".into());
+ cx.notify();
+ });
+ assert!(drawn(&mut vcx, src_ui));
+ assert!(!drawn(&mut vcx, a));
+ assert!(drawn(&mut vcx, readme));
+
+ // A filter drops what does not match — and opens the folded
+ // directory the match is in.
+ app.update_in(&mut vcx, |app, window, cx| {
+ let input = app.scm.filter.clone().expect("the filter row was drawn");
+ input.update(cx, |state, cx| state.set_value("UI A", window, cx));
+ });
+ assert!(drawn(&mut vcx, a));
+ assert!(!drawn(&mut vcx, "scm-row-Untracked-src/ui/b.rs"));
+ assert!(!drawn(&mut vcx, readme));
+ assert!(!drawn(&mut vcx, "scm-dir-Untracked-docs"));
+
+ // The same filter narrows the flat list too.
+ app.update(&mut vcx, |app, cx| app.scm_toggle_changes_tree(cx));
+ assert!(drawn(&mut vcx, a));
+ assert!(!drawn(&mut vcx, readme));
+ assert!(!drawn(&mut vcx, src_ui));
+
+ let _ = std::fs::remove_dir_all(&root);
+ }
}
/// The action strip is the one place in the panel where a hover reveal sits
diff --git a/src/ui/scm/state.rs b/src/ui/scm/state.rs
index a201385f..ef44bef5 100644
--- a/src/ui/scm/state.rs
+++ b/src/ui/scm/state.rs
@@ -96,6 +96,14 @@ pub(crate) struct ScmPanelState {
/// opening one by hand has to outlast the next file landing in it.
pub(crate) collapsed: HashSet,
pub(crate) toggled: HashSet,
+ /// Directories folded shut in the tree view, per group — the same
+ /// directory can be open under Changes and shut under Staged. Keyed by
+ /// the full repo-relative path of the (possibly compacted) row.
+ pub(crate) folded_dirs: HashSet<(ScmGroup, String)>,
+ /// The filter over the changed files. Created on first render like
+ /// `commit_input`, with the subscription that turns typing into a repaint.
+ pub(crate) filter: Option>,
+ pub(crate) filter_sub: Option,
/// Working directory → the repository root containing it, or `None` when
/// there is none, with when the answer was given. The root is what every
/// write runs from and what every cache is keyed by, so it is resolved
@@ -165,6 +173,14 @@ impl ScmPanelState {
}
}
+ /// Fold a tree directory shut, or open it again.
+ pub(crate) fn toggle_dir(&mut self, group: ScmGroup, key: String) {
+ let entry = (group, key);
+ if !self.folded_dirs.remove(&entry) {
+ self.folded_dirs.insert(entry);
+ }
+ }
+
pub(crate) fn set_group_collapsed(&mut self, group: ScmGroup, collapsed: bool) {
self.toggled.insert(group);
if collapsed {
diff --git a/src/ui/scm/tree.rs b/src/ui/scm/tree.rs
new file mode 100644
index 00000000..c0ed93b4
--- /dev/null
+++ b/src/ui/scm/tree.rs
@@ -0,0 +1,298 @@
+//! The changed-file list as a tree, and the filter both views share.
+//!
+//! Pure, and over plain paths rather than `StatusEntry`: the panel hands in
+//! one group's paths and gets back rows that point into that same slice, so
+//! the file rows it draws are the ones the flat list would have drawn — same
+//! letter, same click, same context menu — only indented under a directory.
+//!
+//! Everything here comes out of the status the host already sent. A remote
+//! repository gets the same tree without a single extra read of its disk.
+
+use std::collections::BTreeMap;
+
+/// One line of the tree view.
+#[derive(Clone, PartialEq, Eq, Debug)]
+pub(crate) enum TreeRow {
+ /// A directory, possibly several compacted into one: `src/ui/scm` when
+ /// `src` and `ui` hold nothing but the next directory down. `key` is the
+ /// full repo-relative path, which is what the fold state is keyed by;
+ /// `label` is what the row reads, relative to its parent row.
+ Dir {
+ key: String,
+ label: String,
+ depth: usize,
+ /// Every file underneath, at any depth — what a folded row still
+ /// owes the reader.
+ files: usize,
+ collapsed: bool,
+ },
+ /// A file, by its position in the paths the tree was built from.
+ File { index: usize, depth: usize },
+}
+
+#[derive(Default)]
+struct Node<'a> {
+ dirs: BTreeMap<&'a str, Node<'a>>,
+ files: Vec<(&'a str, usize)>,
+}
+
+impl Node<'_> {
+ fn file_count(&self) -> usize {
+ self.files.len() + self.dirs.values().map(Node::file_count).sum::()
+ }
+}
+
+/// Group `paths` by directory, directories before files at every level and
+/// each run in byte order — the order git itself lists paths in.
+///
+/// A directory whose only content is one more directory is folded into it,
+/// the way VS Code's compact folders do: an agent's change three levels down
+/// `crates/tty7-core/src` should cost one row to reach, not three.
+///
+/// `collapsed` is asked once per directory row with its full path. A folded
+/// directory still gets its own row; only what is under it is left out.
+pub(crate) fn tree_rows<'a>(
+ paths: impl IntoIterator,
+ collapsed: impl Fn(&str) -> bool,
+) -> Vec {
+ let mut root = Node::default();
+ for (index, path) in paths.into_iter().enumerate() {
+ // An untracked directory arrives as `dir/`. It is a leaf here, the
+ // same as it is a single row in the flat list.
+ let trimmed = path.strip_suffix('/').unwrap_or(path);
+ let (dir, name) = match trimmed.rsplit_once('/') {
+ Some((dir, name)) => (Some(dir), name),
+ None => (None, trimmed),
+ };
+ let mut node = &mut root;
+ for part in dir.into_iter().flat_map(|d| d.split('/')) {
+ node = node.dirs.entry(part).or_default();
+ }
+ node.files.push((name, index));
+ }
+ let mut rows = Vec::new();
+ emit(&root, "", 0, &collapsed, &mut rows);
+ rows
+}
+
+fn emit(
+ node: &Node<'_>,
+ prefix: &str,
+ depth: usize,
+ collapsed: &impl Fn(&str) -> bool,
+ rows: &mut Vec,
+) {
+ for (name, child) in &node.dirs {
+ let mut label = (*name).to_string();
+ let mut child = child;
+ while child.files.is_empty() && child.dirs.len() == 1 {
+ let (next, grandchild) = child.dirs.iter().next().expect("one entry");
+ label.push('/');
+ label.push_str(next);
+ child = grandchild;
+ }
+ let key = match prefix {
+ "" => label.clone(),
+ _ => format!("{prefix}/{label}"),
+ };
+ let folded = collapsed(&key);
+ rows.push(TreeRow::Dir {
+ key: key.clone(),
+ label,
+ depth,
+ files: child.file_count(),
+ collapsed: folded,
+ });
+ if !folded {
+ emit(child, &key, depth + 1, collapsed, rows);
+ }
+ }
+ let mut files = node.files.clone();
+ files.sort_by(|a, b| a.0.cmp(b.0));
+ rows.extend(
+ files
+ .into_iter()
+ .map(|(_, index)| TreeRow::File { index, depth }),
+ );
+}
+
+/// The filter box's text, normalised once: lowercased, trimmed, and `None`
+/// when there is nothing left to filter by.
+pub(crate) fn normalize_query(text: &str) -> Option {
+ let q = text.trim().to_lowercase();
+ (!q.is_empty()).then_some(q)
+}
+
+/// Whether a changed path survives the filter.
+///
+/// Every whitespace-separated word has to appear somewhere in the path, in
+/// any order: `scm panel` finds `src/ui/scm/panel.rs`, and so does `panel
+/// scm`. `query` is what [`normalize_query`] returned.
+pub(crate) fn path_matches(path: &str, query: &str) -> bool {
+ let hay = path.to_lowercase();
+ query.split_whitespace().all(|word| hay.contains(word))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn open(paths: &[&str]) -> Vec {
+ tree_rows(paths.iter().copied(), |_| false)
+ }
+
+ fn dir(key: &str, label: &str, depth: usize, files: usize) -> TreeRow {
+ TreeRow::Dir {
+ key: key.into(),
+ label: label.into(),
+ depth,
+ files,
+ collapsed: false,
+ }
+ }
+
+ fn file(index: usize, depth: usize) -> TreeRow {
+ TreeRow::File { index, depth }
+ }
+
+ #[test]
+ fn files_are_grouped_under_their_directories() {
+ let rows = open(&["src/a.rs", "README.md", "src/b.rs", "docs/x.md"]);
+ assert_eq!(
+ rows,
+ vec![
+ dir("docs", "docs", 0, 1),
+ file(3, 1),
+ dir("src", "src", 0, 2),
+ file(0, 1),
+ file(2, 1),
+ // Files after directories, at the root as everywhere else.
+ file(1, 0),
+ ]
+ );
+ }
+
+ #[test]
+ fn a_directory_holding_only_a_directory_is_compacted() {
+ let rows = open(&[
+ "crates/core/src/git/status.rs",
+ "crates/core/src/git/diff.rs",
+ "crates/core/Cargo.toml",
+ ]);
+ assert_eq!(
+ rows,
+ vec![
+ // `crates` holds only `core`, so they share a row; `core`
+ // holds a file, so the chain stops there.
+ dir("crates/core", "crates/core", 0, 3),
+ dir("crates/core/src/git", "src/git", 1, 2),
+ file(1, 2),
+ file(0, 2),
+ file(2, 1),
+ ]
+ );
+ }
+
+ #[test]
+ fn a_directory_with_two_children_is_not_compacted() {
+ let rows = open(&["a/b/x.rs", "a/c/y.rs"]);
+ assert_eq!(
+ rows,
+ vec![
+ dir("a", "a", 0, 2),
+ dir("a/b", "b", 1, 1),
+ file(0, 2),
+ dir("a/c", "c", 1, 1),
+ file(1, 2),
+ ]
+ );
+ }
+
+ #[test]
+ fn a_folded_directory_keeps_its_row_and_its_count() {
+ let rows = tree_rows(["src/ui/a.rs", "src/ui/b.rs", "src/main.rs"], |key| {
+ key == "src/ui"
+ });
+ assert_eq!(
+ rows,
+ vec![
+ dir("src", "src", 0, 3),
+ TreeRow::Dir {
+ key: "src/ui".into(),
+ label: "ui".into(),
+ depth: 1,
+ files: 2,
+ collapsed: true,
+ },
+ file(2, 1),
+ ]
+ );
+ }
+
+ #[test]
+ fn folding_is_asked_about_the_compacted_path() {
+ let asked = std::cell::RefCell::new(Vec::new());
+ tree_rows(["a/b/c/x.rs"], |key| {
+ asked.borrow_mut().push(key.to_string());
+ false
+ });
+ assert_eq!(asked.into_inner(), vec!["a/b/c".to_string()]);
+ }
+
+ #[test]
+ fn an_untracked_directory_is_a_leaf() {
+ let rows = open(&["build/", "src/new/"]);
+ assert_eq!(rows, vec![dir("src", "src", 0, 1), file(1, 1), file(0, 0)]);
+ }
+
+ #[test]
+ fn every_path_lands_in_exactly_one_file_row() {
+ let paths = [
+ "z.rs",
+ "a/b/c.rs",
+ "a/b/d.rs",
+ "a/e.rs",
+ "f/g/h/i.rs",
+ "f/j.rs",
+ "a/b/",
+ ];
+ let mut seen: Vec = open(&paths)
+ .into_iter()
+ .filter_map(|row| match row {
+ TreeRow::File { index, .. } => Some(index),
+ TreeRow::Dir { .. } => None,
+ })
+ .collect();
+ seen.sort_unstable();
+ assert_eq!(seen, (0..paths.len()).collect::>());
+ }
+
+ #[test]
+ fn an_empty_list_is_an_empty_tree() {
+ assert!(open(&[]).is_empty());
+ }
+
+ #[test]
+ fn the_filter_needs_every_word_in_any_order_and_any_case() {
+ let q = normalize_query(" Scm PANEL ").unwrap();
+ assert!(path_matches("src/ui/scm/panel.rs", &q));
+ assert!(path_matches("src/ui/Panel/SCM.rs", &q));
+ assert!(!path_matches("src/ui/scm/state.rs", &q));
+ let q = normalize_query("panel scm").unwrap();
+ assert!(path_matches("src/ui/scm/panel.rs", &q));
+ }
+
+ #[test]
+ fn a_blank_filter_is_no_filter() {
+ assert_eq!(normalize_query(""), None);
+ assert_eq!(normalize_query(" "), None);
+ assert_eq!(normalize_query(" a "), Some("a".into()));
+ }
+
+ #[test]
+ fn the_filter_reads_non_ascii_paths() {
+ let q = normalize_query("设计").unwrap();
+ assert!(path_matches("文档/设计/方案.md", &q));
+ assert!(!path_matches("文档/实现.md", &q));
+ }
+}