feat(scm): filter changed files and show them as a tree (#950)

* feat(scm): filter changed files and show them as a tree

* test(scm): key the tree test's settle on the panel's own root
This commit is contained in:
l0ng-ai
2026-09-25 13:53:44 +08:00
committed by GitHub
parent 575d2cd68e
commit 455e74dc2c
15 changed files with 660 additions and 4 deletions
+17
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg>

After

Width:  |  Height:  |  Size: 311 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12h-8"/><path d="M21 6H8"/><path d="M21 18h-8"/><path d="M3 6v4c0 1.1.9 2 2 2h3"/><path d="M3 10v6c0 1.1.9 2 2 2h3"/></svg>

After

Width:  |  Height:  |  Size: 320 B

+7
View File
@@ -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,
+4
View File
@@ -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(),
+4
View File
@@ -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 => {
+4
View File
@@ -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} 件のみ表示しています。"
}
+4
View File
@@ -913,6 +913,10 @@ l10n_keys! {
ScmShowLess,
ScmCommitNotFound,
ScmTooManyChanges,
ScmFilterChanges,
ScmViewAsTree,
ScmViewAsList,
ScmNoMatchingChanges,
ScmOpenChanges,
ScmDiscardAllConfirm,
ScmAmendConfirm,
+4
View File
@@ -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 => {
+14
View File
@@ -729,6 +729,18 @@ impl Tty7App {
input: &gpui::Entity<gpui_component::input::InputState>,
cx: &mut Context<Self>,
) -> 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<gpui_component::input::InputState>,
trailing: Option<AnyElement>,
cx: &mut Context<Self>,
) -> 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()
}
+10
View File
@@ -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<Self>) {
let next = !cx.global::<crate::core::config::Config>().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`:
+1
View File
@@ -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};
+275 -4
View File
@@ -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<WorkingTreeStatus>,
cx: &mut Context<Self>,
) -> AnyElement {
let query = self.scm_filter_query(cx);
let tree = cx.global::<crate::core::config::Config>().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<Self>) -> 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::<crate::core::config::Config>().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<String> {
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<Self>,
) -> AnyElement {
let sf = cx.global::<crate::ui::presets::Surfaces>().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<usize>,
cx: &mut Context<Self>,
) -> AnyElement {
let sf = cx.global::<crate::ui::presets::Surfaces>().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::<crate::core::config::Config>().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
+16
View File
@@ -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<ScmGroup>,
pub(crate) toggled: HashSet<ScmGroup>,
/// 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<Entity<InputState>>,
pub(crate) filter_sub: Option<gpui::Subscription>,
/// 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 {
+298
View File
@@ -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::<usize>()
}
}
/// 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<Item = &'a str>,
collapsed: impl Fn(&str) -> bool,
) -> Vec<TreeRow> {
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<TreeRow>,
) {
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<String> {
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<TreeRow> {
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<usize> = 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::<Vec<_>>());
}
#[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));
}
}