diff --git a/CHANGELOG.md b/CHANGELOG.md
index d085419c..fdd34533 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [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.
+
### Fixed
- **Nerd Font icons from a fallback font come out at the text's size** (#866).
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/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs
index 8c9650a8..2a9281f3 100644
--- a/crates/tty7-core/src/daemon/pane.rs
+++ b/crates/tty7-core/src/daemon/pane.rs
@@ -3057,6 +3057,14 @@ fn apply_agent_signals(
let before = st.agent_session.clone();
for event in &events {
+ // A hook names the agent that emitted it. Once this pane has a
+ // foreground agent, a different one's session id must not land here:
+ // omp forking with a Claude UUID is "session not found".
+ if let (Some(current), Some(from)) = (st.agent, event.agent)
+ && current != from
+ {
+ continue;
+ }
if st.agent.is_none() && event.agent.is_some() {
st.agent = event.agent;
notify(st, DaemonMsg::Agent(st.agent));
@@ -3183,7 +3191,9 @@ fn apply_agent(
stamp_launch_argv(st, argv);
return;
}
- if agent.is_none() && st.agent_session.is_some() {
+ // The session belongs to whoever was in the foreground. Switching from
+ // Claude to omp (or back to the shell) must not keep the previous id.
+ if st.agent_session.is_some() {
st.agent_session = None;
notify(st, DaemonMsg::AgentStatus(None));
}
@@ -5378,6 +5388,39 @@ mod tests {
assert!(matches!(rx.try_recv(), Ok(DaemonMsg::Agent(None))));
}
+ #[test]
+ fn a_foreign_agent_hook_does_not_replace_the_foreground_session() {
+ use crate::core::cli_agent::CLIAgent;
+
+ let mut st = test_state(true);
+ let mut sniffer = OscSniffer::new();
+ let omp = concat!(
+ "\x1b]777;notify;tty7://cli-agent;",
+ r#"{"v":1,"agent":"omp","event":"session-start","session_id":"01a0d21f-2f79-72e0-bb16-d6d908aaa6e0"}"#,
+ "\x07",
+ );
+ apply_signals(&mut st, sniffer.feed(omp.as_bytes()));
+ assert_eq!(st.agent, Some(CLIAgent::OhMyPi));
+
+ let claude = concat!(
+ "\x1b]777;notify;tty7://cli-agent;",
+ r#"{"v":1,"agent":"claude","event":"session-start","session_id":"e18a20ff-4c8c-4b94-867b-dd4b79032a6c"}"#,
+ "\x07",
+ );
+ apply_signals(&mut st, sniffer.feed(claude.as_bytes()));
+ assert_eq!(
+ st.agent_session.as_ref().unwrap().session_id.as_deref(),
+ Some("01a0d21f-2f79-72e0-bb16-d6d908aaa6e0"),
+ "a Claude hook on an omp pane must not steal the session id"
+ );
+
+ apply_agent(&mut st, Some((CLIAgent::Claude, vec!["claude".into()])));
+ assert!(
+ st.agent_session.is_none(),
+ "switching the foreground agent drops the previous session"
+ );
+ }
+
#[test]
fn opaque_notifications_only_fall_back_when_no_rich_state() {
use crate::core::cli_agent::{AgentSessionState, AgentStatus, CLIAgent};
diff --git a/crates/tty7-core/src/daemon/procinfo.rs b/crates/tty7-core/src/daemon/procinfo.rs
index 217a530b..e55581fb 100644
--- a/crates/tty7-core/src/daemon/procinfo.rs
+++ b/crates/tty7-core/src/daemon/procinfo.rs
@@ -225,23 +225,15 @@ fn walk(table: &HashMap, shell_pid: u32, fg_pgid: Option) -> Vec<
#[cfg(target_os = "macos")]
fn process_table() -> HashMap {
let mut table = HashMap::new();
- let bytes = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) };
- if bytes <= 0 {
- return table;
- }
- let cap = (bytes as usize / std::mem::size_of::()) + 64;
- let mut pids = vec![0 as libc::c_int; cap];
- let written = unsafe {
- libc::proc_listallpids(
- pids.as_mut_ptr() as *mut libc::c_void,
- (cap * std::mem::size_of::()) as libc::c_int,
- )
- };
- if written <= 0 {
- return table;
- }
- let n = written as usize / std::mem::size_of::();
- for &pid in pids.iter().take(n.min(cap)) {
+ let pids = list_all_pids(|buf| {
+ let ptr = match buf.is_empty() {
+ true => std::ptr::null_mut(),
+ false => buf.as_mut_ptr() as *mut libc::c_void,
+ };
+ let bytes = std::mem::size_of_val(buf) as libc::c_int;
+ unsafe { libc::proc_listallpids(ptr, bytes) }
+ });
+ for &pid in &pids {
if pid <= 0 {
continue;
}
@@ -276,6 +268,37 @@ fn process_table() -> HashMap {
table
}
+/// Every pid on the machine, by way of `list`, which has the calling
+/// convention of libproc's `proc_listallpids`: handed a buffer, it fills as
+/// much of it as it can, and it answers with a *count of pids* — not a byte
+/// count, although the size it is given is in bytes. An empty buffer asks
+/// only for the count, padded by the kernel for processes born in between.
+///
+/// Reading that count as bytes was #731. It divided the answer by four twice
+/// over, once sizing the buffer and once reading back how much was filled, so
+/// only about one pid in sixteen made it into the table — and since the kernel
+/// lists its newest processes first, the ones kept were whatever had started
+/// most recently. A `go run` server just launched was in; the pane's shell,
+/// started long before, was not, and a walk from a root the table has never
+/// heard of comes back empty: no processes, no ports, and nothing to say why.
+/// A process tree built moments earlier, which is what a quick check of the
+/// probe builds, is exactly the case that happened to work.
+#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
+fn list_all_pids(mut list: impl FnMut(&mut [i32]) -> i32) -> Vec {
+ let count = list(&mut []);
+ if count <= 0 {
+ return Vec::new();
+ }
+ let mut pids = vec![0; count as usize + 64];
+ let written = list(&mut pids);
+ if written <= 0 {
+ return Vec::new();
+ }
+ pids.truncate(written as usize);
+ pids.retain(|&pid| pid > 0);
+ pids
+}
+
#[cfg(target_os = "macos")]
fn cstr_field(buf: &[libc::c_char]) -> String {
let bytes: Vec = buf
@@ -968,6 +991,40 @@ mod tests {
);
}
+ /// `proc_listallpids` as libproc implements it: the kernel's newest
+ /// processes first, a padded estimate when asked with no buffer, and
+ /// always a count of pids back, never bytes.
+ fn fake_listallpids(live: &[i32]) -> impl FnMut(&mut [i32]) -> i32 + '_ {
+ move |buf: &mut [i32]| {
+ if buf.is_empty() {
+ return live.len() as i32 + 20;
+ }
+ let n = live.len().min(buf.len());
+ buf[..n].copy_from_slice(&live[..n]);
+ n as i32
+ }
+ }
+
+ /// #731 on macOS: a busy Mac has hundreds of processes, and the pane's
+ /// shell is an old one. Every pid has to reach the table, or the walk
+ /// starts from a root it cannot find and the pane shows no processes and
+ /// no ports at all.
+ #[test]
+ fn every_pid_is_listed_not_the_newest_sixteenth() {
+ // Newest first, the way the kernel lists them: the `go run` server
+ // just started, and the shell it runs under from an hour ago.
+ let live: Vec = (1..=700).rev().collect();
+ let got = list_all_pids(fake_listallpids(&live));
+ assert_eq!(got.len(), 700, "the whole machine, not the newest few");
+ assert!(got.contains(&1), "the oldest process is still there");
+ }
+
+ #[test]
+ fn a_pid_list_that_cannot_be_read_is_empty() {
+ assert!(list_all_pids(|_| -1).is_empty());
+ assert!(list_all_pids(|_| 0).is_empty());
+ }
+
#[test]
fn a_pathological_tree_still_stops() {
let mut table: HashMap = [(100, row(1, "zsh"))].into_iter().collect();
diff --git a/src/main.rs b/src/main.rs
index 8a9500df..76c9559d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -705,6 +705,7 @@ fn main() {
.detach();
gpui_component::init(cx);
register_bundled_fonts(cx);
+ crate::ui::prompt::install(cx);
cx.activate(true);
#[cfg(target_os = "macos")]
set_dock_icon_for_bare_binary();
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/mod.rs b/src/ui/mod.rs
index 4ec0d0af..df167e08 100644
--- a/src/ui/mod.rs
+++ b/src/ui/mod.rs
@@ -27,6 +27,7 @@ pub mod pending_pane;
pub mod perf;
pub mod prefill;
pub mod presets;
+pub mod prompt;
pub mod remote_connect;
pub mod remote_workspace;
pub mod reorder;
diff --git a/src/ui/prompt.rs b/src/ui/prompt.rs
new file mode 100644
index 00000000..80c6459e
--- /dev/null
+++ b/src/ui/prompt.rs
@@ -0,0 +1,180 @@
+//! The confirmation dialog on platforms with no native one.
+//!
+//! macOS and Windows answer `window.prompt` with NSAlert and TaskDialog. Linux
+//! has neither, so gpui falls back to a renderer of its own — and that one sets
+//! the message and the detail as single unbreakable lines inside a fixed-width
+//! box that clips its overflow. Any sentence longer than the box is cut off
+//! mid-word, which for the quit-and-stop warning is exactly the part that says
+//! what will be lost (#920). This is the same dialog laid out as a card that
+//! wraps its text, in the app's own surfaces, with Return and Escape answering
+//! the way they do on the native dialogs.
+
+use gpui::{
+ App, Context, EventEmitter, FocusHandle, Focusable, PromptButton, PromptHandle, PromptLevel,
+ PromptResponse, RenderablePromptHandle, Window, div, prelude::*, px, relative,
+};
+use gpui_component::button::{Button, ButtonVariants as _};
+use gpui_component::{ActiveTheme as _, Sizable as _, h_flex, v_flex};
+
+/// Route every `window.prompt` through [`TextPrompt`] where the platform has
+/// no dialog of its own to show.
+pub(crate) fn install(cx: &mut App) {
+ if cfg!(any(target_os = "macos", target_os = "windows")) {
+ return;
+ }
+ cx.set_prompt_builder(build);
+}
+
+fn build(
+ _level: PromptLevel,
+ message: &str,
+ detail: Option<&str>,
+ answers: &[PromptButton],
+ handle: PromptHandle,
+ window: &mut Window,
+ cx: &mut App,
+) -> RenderablePromptHandle {
+ let prompt = cx.new(|cx| TextPrompt::new(message, detail, answers, cx));
+ handle.with_view(prompt, window, cx)
+}
+
+pub(crate) struct TextPrompt {
+ message: String,
+ detail: Option,
+ answers: Vec,
+ focus: FocusHandle,
+}
+
+impl TextPrompt {
+ fn new(
+ message: &str,
+ detail: Option<&str>,
+ answers: &[PromptButton],
+ cx: &mut Context,
+ ) -> Self {
+ Self {
+ message: message.to_string(),
+ detail: detail.map(str::to_string),
+ answers: answers.to_vec(),
+ focus: cx.focus_handle(),
+ }
+ }
+
+ /// The answer a key picks. Return is answer 0 — `confirm_answers` puts the
+ /// action there for exactly this — and Escape is the one marked cancel;
+ /// a prompt with no cancel answer has nothing Escape can safely mean.
+ fn answer_for_key(&self, key: &str) -> Option {
+ match key {
+ "enter" if !self.answers.is_empty() => Some(0),
+ "escape" => self.answers.iter().position(PromptButton::is_cancel),
+ _ => None,
+ }
+ }
+}
+
+impl EventEmitter for TextPrompt {}
+
+impl Focusable for TextPrompt {
+ fn focus_handle(&self, _: &App) -> FocusHandle {
+ self.focus.clone()
+ }
+}
+
+impl Render for TextPrompt {
+ fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement {
+ let muted = cx.theme().muted_foreground;
+ // Answer 0 goes rightmost, where the native dialogs put it and where
+ // `confirm_answers` expects it to land.
+ let buttons = self.answers.iter().enumerate().rev().map(|(ix, answer)| {
+ Button::new(("prompt-answer", ix))
+ .label(answer.label().clone())
+ .small()
+ .when(ix == 0 && !answer.is_cancel(), |b| b.primary())
+ .on_click(cx.listener(move |_, _, _, cx| {
+ cx.emit(PromptResponse(ix));
+ cx.stop_propagation();
+ }))
+ });
+
+ let card = v_flex()
+ .occlude()
+ .w(px(420.))
+ .max_w(relative(0.9))
+ .gap_3()
+ .p_5()
+ .map(|card| crate::ui::theme::floating_surface(card, cx))
+ .child(
+ div()
+ .text_sm()
+ .font_weight(gpui::FontWeight::SEMIBOLD)
+ .child(self.message.clone()),
+ )
+ .children(
+ self.detail
+ .clone()
+ .map(|detail| div().text_sm().text_color(muted).child(detail)),
+ )
+ .child(
+ h_flex()
+ .pt_2()
+ .justify_end()
+ .flex_wrap()
+ .gap_2()
+ .children(buttons),
+ );
+
+ div()
+ .id("text-prompt")
+ .track_focus(&self.focus)
+ .size_full()
+ .cursor_default()
+ .bg(crate::ui::presets::scrim_fill(cx))
+ .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, _, cx| {
+ if let Some(ix) = this.answer_for_key(&ev.keystroke.key) {
+ cx.emit(PromptResponse(ix));
+ cx.stop_propagation();
+ }
+ }))
+ .flex()
+ .items_center()
+ .justify_center()
+ .child(card)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::TextPrompt;
+ use gpui::{AppContext as _, PromptButton, TestAppContext};
+
+ fn prompt(cx: &mut TestAppContext, answers: &[PromptButton]) -> gpui::Entity {
+ cx.update(|cx| {
+ cx.new(|cx| {
+ TextPrompt::new(
+ "Quit and stop tty7 server?",
+ Some("a detail long enough to need more than one line"),
+ answers,
+ cx,
+ )
+ })
+ })
+ }
+
+ #[gpui::test]
+ fn return_confirms_and_escape_takes_the_cancel_answer(cx: &mut TestAppContext) {
+ let p = prompt(cx, &crate::ui::confirm_answers("Quit", "Cancel"));
+ p.read_with(cx, |p, _| {
+ assert_eq!(p.answer_for_key("enter"), Some(0));
+ assert_eq!(p.answer_for_key("escape"), Some(1));
+ assert_eq!(p.answer_for_key("a"), None);
+ });
+ }
+
+ #[gpui::test]
+ fn escape_does_nothing_without_a_cancel_answer(cx: &mut TestAppContext) {
+ let p = prompt(cx, &[PromptButton::ok("OK"), PromptButton::new("Retry")]);
+ p.read_with(cx, |p, _| {
+ assert_eq!(p.answer_for_key("escape"), None);
+ });
+ }
+}
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));
+ }
+}