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)); + } +}