mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-10-02 00:02:00 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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} 件のみ表示しています。"
|
||||
}
|
||||
|
||||
@@ -913,6 +913,10 @@ l10n_keys! {
|
||||
ScmShowLess,
|
||||
ScmCommitNotFound,
|
||||
ScmTooManyChanges,
|
||||
ScmFilterChanges,
|
||||
ScmViewAsTree,
|
||||
ScmViewAsList,
|
||||
ScmNoMatchingChanges,
|
||||
ScmOpenChanges,
|
||||
ScmDiscardAllConfirm,
|
||||
ScmAmendConfirm,
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user