From e780feacf2abc9a54cc5634e82e5fee94e1fcaef Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:16:53 +0200 Subject: [PATCH] feat(search): quick open a file by name from a Files tab Search Everywhere gains a Files tab that finds any file in the active tab's project by fuzzy name and opens it in the built-in editor, with `name:line[:col]` jumping to that spot. The list comes from one walk of the project through the host (Host::search with an empty query), so it works the same on local, SSH and WSL workspaces and skips what the tree hides: dotfiles, .git and gitignored paths. The walk is capped at 50k entries / 20k directories, kept between openings and revalidated in the background each time the search opens. Files join the All tab once a query finds them. Go to File... is bound to Cmd+O on macOS (Cmd+P is already Search Everywhere) and ships unbound elsewhere, where every obvious chord is taken or owed to the shell. --- src/core/actions.rs | 1 + src/ui/app.rs | 9 + src/ui/file_tree.rs | 5 + src/ui/i18n/en.rs | 13 +- src/ui/i18n/ja.rs | 15 +- src/ui/i18n/mod.rs | 9 + src/ui/i18n/zh.rs | 11 +- src/ui/keymap.rs | 10 + src/ui/search/command.rs | 14 + src/ui/search/files.rs | 743 +++++++++++++++++++++++++++++++++++++++ src/ui/search/mod.rs | 21 +- src/ui/search/sources.rs | 78 +++- src/ui/search/view.rs | 158 ++++++++- src/ui/theme.rs | 1 + 14 files changed, 1064 insertions(+), 24 deletions(-) create mode 100644 src/ui/search/files.rs diff --git a/src/core/actions.rs b/src/core/actions.rs index 5f20595f..1fb5be95 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -73,6 +73,7 @@ actions!( DecreaseFontSize, ResetFontSize, TogglePalette, + QuickOpenFile, ReopenClosedTab, ToggleMaximizePane, ToggleFullscreen, diff --git a/src/ui/app.rs b/src/ui/app.rs index 700502f8..2d0250f6 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -5740,6 +5740,7 @@ impl Tty7App { let (sessions, here) = self.search_sessions(last, window, cx); catalog.sessions = sessions; catalog.sessions_here = here; + catalog.files = self.file_list_now(cx); catalog } @@ -6003,6 +6004,7 @@ impl Tty7App { self.search_sub = Some(cx.subscribe_in(&view, window, Self::on_search_event)); self.search = Some(view.clone()); self.refresh_search_sessions(view, window, cx); + self.refresh_file_index(window, cx); cx.notify(); } @@ -6284,6 +6286,10 @@ impl Tty7App { // Both are the search's own, and handled inside it. OpenThemePicker => {} SearchHosts => self.open_search(SearchTab::Hosts, "", window, cx), + QuickOpenFile => self.open_search(SearchTab::Files, "", window, cx), + OpenFile { path, line, column } => { + self.open_indexed_file(&path, line, column, window, cx) + } GoToTab { workspace, tab } => self.go_to_tab(workspace, tab, false, window, cx), ResumeSession { agent, @@ -9159,6 +9165,9 @@ impl Render for Tty7App { this.toggle_search(window, cx) }), ) + .on_action(cx.listener(|this, _: &QuickOpenFile, window, cx| { + this.quick_open_file(window, cx) + })) .on_action(cx.listener(|this, _: &ReopenClosedTab, window, cx| { this.reopen_closed_tab(window, cx) })) diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index 5f5f2fbd..30cc6fd1 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -207,6 +207,10 @@ pub(crate) struct FileTreeState { repo_roots: ByHost, repo_root_loads: InFlight, search: SearchState, + /// Every file under the project, for the search's Files tab + /// (`ui::search::files`). Kept here, beside the tree it is a flat copy of, + /// so it outlives the search it was walked for. + pub(crate) quick_open: crate::ui::search::FileIndexStore, pub(crate) show_hidden: bool, pub(crate) editing: Option, editing_subs: Vec, @@ -250,6 +254,7 @@ impl FileTreeState { repo_roots: ByHost::default(), repo_root_loads: InFlight::default(), search: SearchState::default(), + quick_open: Default::default(), show_hidden: false, editing: None, editing_subs: Vec::new(), diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 37763857..170cb096 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -125,10 +125,21 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SearchTabActions => "Actions", L10nKey::SearchTabTerminals => "Terminals", L10nKey::SearchTabHosts => "Hosts", - L10nKey::SearchPlaceholderAll => "Search actions, terminals and hosts…", + L10nKey::SearchPlaceholderAll => "Search files, actions, terminals and hosts…", L10nKey::SearchPlaceholderActions => "Search actions…", L10nKey::SearchPlaceholderTerminals => "Search open tabs, shells and agents…", L10nKey::SearchPlaceholderHosts => "Search hosts, or type user@host to connect…", + L10nKey::SearchTabFiles => "Files", + L10nKey::SearchPlaceholderFiles => "Go to a file by name — add :line to jump to it…", + L10nKey::SearchFilesNoRoots => "No project to search", + L10nKey::SearchFilesNoRootsHint => { + "Files are found in the project your terminal is in. cd into one to search it." + } + L10nKey::SearchFilesIndexing => "Indexing files…", + L10nKey::SearchFilesFailed => "The project's files could not be listed.", + L10nKey::SearchFilesGoToLine => "line {line}", + L10nKey::SearchFilesCapped => "Large project — only the first {count} files are searched", + L10nKey::CmdGoToFile => "Go to File…", L10nKey::SearchTabSessions => "Sessions", L10nKey::SearchPlaceholderSessions => "Search past agent sessions…", L10nKey::SearchSessionsEmptyHint => { diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 8e4aef12..befbe27e 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -126,10 +126,23 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SearchTabActions => "アクション", L10nKey::SearchTabTerminals => "ターミナル", L10nKey::SearchTabHosts => "ホスト", - L10nKey::SearchPlaceholderAll => "アクション、ターミナル、ホストを検索…", + L10nKey::SearchPlaceholderAll => "ファイル、アクション、ターミナル、ホストを検索…", L10nKey::SearchPlaceholderActions => "アクションを検索…", L10nKey::SearchPlaceholderTerminals => "開いているタブ、シェル、エージェントを検索…", L10nKey::SearchPlaceholderHosts => "ホストを検索、または user@host を入力して接続…", + L10nKey::SearchTabFiles => "ファイル", + L10nKey::SearchPlaceholderFiles => "名前でファイルに移動(:行番号 でその行へ)…", + L10nKey::SearchFilesNoRoots => "検索するプロジェクトがありません", + L10nKey::SearchFilesNoRootsHint => { + "ファイルはターミナルのいるプロジェクトから探します。プロジェクトに cd すると検索できます。" + } + L10nKey::SearchFilesIndexing => "ファイルをインデックス中…", + L10nKey::SearchFilesFailed => "このプロジェクトのファイルを一覧できませんでした。", + L10nKey::SearchFilesGoToLine => "{line} 行目", + L10nKey::SearchFilesCapped => { + "大きなプロジェクトのため、最初の {count} 件のファイルのみ検索します" + } + L10nKey::CmdGoToFile => "ファイルに移動…", L10nKey::SearchTabSessions => "セッション", L10nKey::SearchPlaceholderSessions => "過去のエージェントセッションを検索…", L10nKey::SearchSessionsEmptyHint => { diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 0833ac34..1d11ffb8 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -178,6 +178,15 @@ l10n_keys! { SearchPlaceholderActions, SearchPlaceholderTerminals, SearchPlaceholderHosts, + SearchTabFiles, + SearchPlaceholderFiles, + SearchFilesNoRoots, + SearchFilesNoRootsHint, + SearchFilesIndexing, + SearchFilesFailed, + SearchFilesGoToLine, + SearchFilesCapped, + CmdGoToFile, SearchTabSessions, SearchPlaceholderSessions, SearchSessionsEmptyHint, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 45da92e0..0f32d258 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -110,10 +110,19 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SearchTabActions => "操作", L10nKey::SearchTabTerminals => "终端", L10nKey::SearchTabHosts => "主机", - L10nKey::SearchPlaceholderAll => "搜索操作、终端和主机…", + L10nKey::SearchPlaceholderAll => "搜索文件、操作、终端和主机…", L10nKey::SearchPlaceholderActions => "搜索操作…", L10nKey::SearchPlaceholderTerminals => "搜索已打开的标签页、Shell 和 Agent…", L10nKey::SearchPlaceholderHosts => "搜索主机,或输入 user@host 连接…", + L10nKey::SearchTabFiles => "文件", + L10nKey::SearchPlaceholderFiles => "按名称转到文件,加上 :行号 可直接跳转…", + L10nKey::SearchFilesNoRoots => "没有可搜索的项目", + L10nKey::SearchFilesNoRootsHint => "文件在终端所在的项目中查找。cd 进入一个项目即可搜索。", + L10nKey::SearchFilesIndexing => "正在索引文件…", + L10nKey::SearchFilesFailed => "无法列出这个项目的文件。", + L10nKey::SearchFilesGoToLine => "第 {line} 行", + L10nKey::SearchFilesCapped => "大型项目——只搜索前 {count} 个文件", + L10nKey::CmdGoToFile => "转到文件…", L10nKey::SearchTabSessions => "会话", L10nKey::SearchPlaceholderSessions => "搜索历史 Agent 会话…", L10nKey::SearchSessionsEmptyHint => "这里列出本机上各编码 agent 的历史会话。", diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 744933b1..fe4ec1e2 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -443,6 +443,14 @@ fn shipped_bindings() -> Vec<(&'static str, &'static str)> { "TogglePalette", per_platform("secondary-p", "secondary-shift-p"), ), + // VS Code's ⌘P, which here is already Search Everywhere. ⌘O instead: + // it is "Open…" in every Mac app, and nothing in the table or the + // terminal holds it. Off macOS the obvious chords are gone — Ctrl+P + // and Ctrl+O are bytes the shell is owed, Ctrl+Shift+P is Search + // Everywhere and Ctrl+Shift+O the workspace switcher — so it ships + // unbound there, like `NewWindow`, rather than on a chord nobody would + // guess. The Files tab is a Tab press away inside the search either way. + ("QuickOpenFile", per_platform("secondary-o", "")), ( "ReopenClosedTab", per_platform("secondary-shift-t", "alt-shift-t"), @@ -910,6 +918,7 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> { CommandGroup::Application, t(L10nKey::AppMenuSearchEverywhere).to_string(), ), + "QuickOpenFile" => (CommandGroup::View, t(L10nKey::CmdGoToFile).to_string()), "NewWindow" => ( CommandGroup::Application, t(L10nKey::CmdNewWindow).to_string(), @@ -1492,6 +1501,7 @@ fn make_binding(action: &str, keystroke: &str) -> Option { "DecreaseFontSize" => KeyBinding::new(keystroke, DecreaseFontSize, None), "ResetFontSize" => KeyBinding::new(keystroke, ResetFontSize, None), "TogglePalette" => KeyBinding::new(keystroke, TogglePalette, None), + "QuickOpenFile" => KeyBinding::new(keystroke, QuickOpenFile, None), "ReopenClosedTab" => KeyBinding::new(keystroke, ReopenClosedTab, None), "ToggleMaximizePane" => KeyBinding::new(keystroke, ToggleMaximizePane, None), "ToggleFullscreen" => KeyBinding::new(keystroke, ToggleFullscreen, None), diff --git a/src/ui/search/command.rs b/src/ui/search/command.rs index c0952e13..9214b07b 100644 --- a/src/ui/search/command.rs +++ b/src/ui/search/command.rs @@ -107,6 +107,15 @@ pub enum CommandKind { OpenThemePicker, /// Moves the search to its Hosts tab, where typing an address connects. SearchHosts, + /// Moves the search to its Files tab — quick open by name. + QuickOpenFile, + /// A file the Files tab found, opened in the editor — on `line` and + /// `column` when the query named them (`main.rs:120:5`). + OpenFile { + path: std::path::PathBuf, + line: Option, + column: Option, + }, /// Connect with a typed `ssh` command line (`-p`, `-J`, an alias…). OpenSshConnect(String), SetTheme(usize), @@ -254,11 +263,13 @@ impl CommandKind { SendGitDiffToAgent => "agent-send-diff", OpenThemePicker => "change-theme", SearchHosts => "ssh-add-connection", + QuickOpenFile => "go-to-file", OpenSshProfiles => "ssh-manage-profiles", SaveSshSessionAsHost => "ssh-save-connection", OpenSshConnect(_) | SetTheme(_) | GoToTab { .. } + | OpenFile { .. } | ResumeSession { .. } | ForkSession { .. } | CopySessionId(_) @@ -377,6 +388,7 @@ impl CommandKind { ScmCreateBranch => "ScmCreateBranch", OpenBranchPicker => "ScmCheckoutBranch", ToggleDiffViewMode => "ToggleDiffViewMode", + QuickOpenFile => "QuickOpenFile", CopyText | CutText | PasteText @@ -393,6 +405,7 @@ impl CommandKind { | OpenSshConnect(_) | SetTheme(_) | GoToTab { .. } + | OpenFile { .. } | ResumeSession { .. } | ForkSession { .. } | CopySessionId(_) @@ -628,6 +641,7 @@ impl Item { ToggleRightPanel, ), Item::localized(L10nKey::CmdShowCodePanel, ToggleCodePanel), + Item::localized(L10nKey::CmdGoToFile, QuickOpenFile), Item::localized( if document_filled { L10nKey::CmdDocumentDock diff --git a/src/ui/search/files.rs b/src/ui/search/files.rs new file mode 100644 index 00000000..88170b4b --- /dev/null +++ b/src/ui/search/files.rs @@ -0,0 +1,743 @@ +//! The Files tab: open a file in the editor by typing part of its name, the +//! way ⌘P does in VS Code, instead of walking the tree to it. +//! +//! The list comes from one walk of the project behind the active tab's panes, +//! done on the host that owns them through [`Host::search`](tty7_core::host::Host::search) +//! — the same breadth-first, `.gitignore`-aware walk the Files panel's filter +//! runs, asked for every name at once. It is kept between openings and shown +//! at once when the search opens again, while a fresh walk runs behind it +//! (stale-while-revalidate): the list you already had is a better answer +//! than a spinner, and the one that replaces it lands under the cursor +//! without moving the highlighted row. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use gpui::{App, Context, Window}; + +use super::SearchTab; +use super::command::{CommandKind, Item}; +use super::score::fuzzy_score; +use super::sources::{Row, Section, Source}; +use crate::ui::app::Tty7App; +use crate::ui::host_ops::{Host, HostId, HostOps}; +use crate::ui::i18n::{L10nKey, t_fmt}; + +/// How many entries one walk may return, directories included. A project +/// past this is mostly generated or vendored files, and a list the size of +/// the cap is already more than anyone scrolls; the walk is breadth-first, so +/// what the cap cuts off is the deepest part of the tree. +pub(crate) const INDEX_LIMIT: usize = 50_000; + +/// How many directories one walk may open. Bounds a walk over a slow link as +/// much as it bounds the list: each directory is a round trip on a remote host. +pub(crate) const INDEX_MAX_DIRS: usize = 20_000; + +/// A search that opens again this soon after a walk landed reuses it rather +/// than walking again. Opening and closing the search in quick succession is +/// someone looking for the right tab, not a sign the tree changed. +const REVALIDATE_AFTER: Duration = Duration::from_secs(5); + +/// Rows the Files tab shows before anything is typed. A sample, shallowest +/// first, so the tab is visibly populated; the query is what finds things. +const BROWSE_ROWS: usize = 100; + +/// Rows a query may bring back. Past this the ranking is noise, and every row +/// is an `Item` built on each keystroke. +const SEARCH_ROWS: usize = 200; + +/// What a match only on the directory part of a path gives up against one on +/// the file's own name. Typing `app` means `app.rs` far more often than +/// `apps/web/index.ts`, but a query with a `/` in it can only match the path, +/// and those still have to come back. +const PATH_ONLY_PENALTY: i32 = 15; + +/// One file the walk found. +#[derive(Debug)] +pub(crate) struct IndexedFile { + /// Where it is on the host, as the host spells it. + pub path: PathBuf, + /// Where it is under its project root — the text the query matches and + /// the row shows. With more than one root it starts with the root's name, + /// so two `src/main.rs` in two projects can be told apart. + pub rel: String, + /// Where the file's own name starts in `rel`. + pub name_at: usize, + /// `rel` lowercased once, for the prefilter that runs on every keystroke. + lower: String, +} + +impl IndexedFile { + pub(crate) fn name(&self) -> &str { + &self.rel[self.name_at..] + } + + /// The directory part of `rel`, without its trailing separator. Empty for + /// a file at the root. + pub(crate) fn dir(&self) -> &str { + self.rel[..self.name_at].trim_end_matches(['/', '\\']) + } +} + +#[derive(Debug, Default)] +pub(crate) struct FileIndex { + /// In the order the walk found them: shallowest first. + pub files: Vec, + /// Whether the walk stopped at [`INDEX_LIMIT`]. One cut short by + /// [`INDEX_MAX_DIRS`] cannot tell, and is not flagged. + pub capped: bool, +} + +/// What the Files tab has to offer right now. +#[derive(Clone, Debug, Default)] +pub(crate) enum FileList { + /// No pane has a directory worth searching: none reported one, or the one + /// it reported is the home directory, which is not a project and whose + /// walk would be all caches and downloads. + #[default] + NoRoots, + /// The first walk of these folders has not come back yet. + Indexing, + /// The host could not walk them. + Failed, + Ready(Arc), +} + +/// A query as the Files tab reads it: the name to look for, and the place in +/// the file to land on when `:line` or `:line:column` follows it — what a +/// compiler prints and what people paste. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct FileQuery<'a> { + pub needle: &'a str, + pub line: Option, + pub column: Option, +} + +pub(crate) fn parse_query(query: &str) -> FileQuery<'_> { + let query = query.trim(); + // A trailing colon is a line number on its way; it is not part of a name. + let mut rest = query.strip_suffix(':').unwrap_or(query); + let mut numbers: Vec = Vec::new(); + while numbers.len() < 2 { + let Some((head, tail)) = rest.rsplit_once(':') else { + break; + }; + if head.is_empty() || tail.is_empty() || !tail.bytes().all(|b| b.is_ascii_digit()) { + break; + } + let Ok(n) = tail.parse::() else { break }; + numbers.push(n); + rest = head; + } + // Read right to left, so the last number found is the line. + numbers.reverse(); + let nonzero = |n: Option<&u32>| n.copied().filter(|n| *n > 0); + FileQuery { + needle: rest, + line: nonzero(numbers.first()), + column: nonzero(numbers.get(1)), + } +} + +/// The folders to walk, from the ones the panes resolved to. +/// +/// Home and a filesystem root are left out: neither is a project, and a walk +/// of either spends the whole budget on caches before it reaches anything +/// anyone meant to open. A folder inside another one on the list is left out +/// too — the outer walk already covers it, and listing it twice would put +/// every file in the results twice. +pub(crate) fn project_roots(resolved: Vec, home: Option<&Path>) -> Vec { + let mut roots: Vec = Vec::new(); + for root in resolved { + let is_home = home.is_some_and(|home| root == home); + let is_fs_root = root.parent().is_none(); + if is_home || is_fs_root || roots.contains(&root) { + continue; + } + roots.push(root); + } + let all = roots.clone(); + roots.retain(|r| !all.iter().any(|other| other != r && r.starts_with(other))); + roots +} + +/// The index for `files` found under `roots`. +pub(crate) fn build_index(roots: &[PathBuf], files: Vec, capped: bool) -> FileIndex { + let many = roots.len() > 1; + let files = files + .into_iter() + .filter_map(|path| { + let text = path.to_string_lossy().into_owned(); + // By text rather than `Path::strip_prefix`: a remote host's paths + // may be Windows paths this client parses as one component. + let (root, under) = roots + .iter() + .filter_map(|root| { + let root = root.to_string_lossy(); + let under = text.strip_prefix(root.as_ref())?; + let under = under.trim_start_matches(['/', '\\']); + // `/repo-old/x` is not under `/repo`. + (under.len() < text.len() - root.len() || root.ends_with(['/', '\\'])) + .then(|| (root.into_owned(), under.to_string())) + }) + .max_by_key(|(root, _)| root.len())?; + if under.is_empty() { + return None; + } + let rel = match many { + true => { + let name = root + .trim_end_matches(['/', '\\']) + .rsplit(['/', '\\']) + .next() + .unwrap_or_default(); + format!("{name}/{under}") + } + false => under, + }; + let name_at = rel.rfind(['/', '\\']).map_or(0, |i| i + 1); + let lower = rel.to_lowercase(); + Some(IndexedFile { + path, + rel, + name_at, + lower, + }) + }) + .collect(); + FileIndex { files, capped } +} + +/// Whether every character of `needle` (already lowercase, no whitespace) +/// appears in `hay` in order. Cheap enough to run over the whole index on +/// every keystroke, and it throws out nearly everything before the real +/// scorer, which allocates, sees it. +fn is_subsequence(needle: &[char], hay: &str) -> bool { + let mut want = needle.iter().peekable(); + for c in hay.chars() { + match want.peek() { + Some(w) if **w == c => { + want.next(); + } + Some(_) => {} + None => break, + } + } + want.peek().is_none() +} + +/// How well `file` answers `needle`, on the one scale every tab shares — so +/// the All tab can put a file beside an action and have the order mean +/// something. A hit on the file's own name wins over the same hit spread +/// across its directories. +pub(crate) fn file_score(needle: &str, file: &IndexedFile) -> Option { + let on_name = fuzzy_score(needle, file.name()); + let on_path = fuzzy_score(needle, &file.rel).map(|s| s - PATH_ONLY_PENALTY); + on_name.into_iter().chain(on_path).max() +} + +/// The files that match `needle`, best first, at most `limit` of them. Ties +/// go to the shorter path — the shallower file is the likelier one. +pub(crate) fn rank<'a>( + index: &'a FileIndex, + needle: &str, + limit: usize, +) -> Vec<(i32, &'a IndexedFile)> { + let chars: Vec = needle + .chars() + .flat_map(char::to_lowercase) + .filter(|c| !c.is_whitespace()) + .collect(); + if chars.is_empty() { + return Vec::new(); + } + let mut hits: Vec<(i32, &IndexedFile)> = index + .files + .iter() + .filter(|f| is_subsequence(&chars, &f.lower)) + .filter_map(|f| Some((file_score(needle, f)?, f))) + .collect(); + hits.sort_by(|(a, fa), (b, fb)| b.cmp(a).then_with(|| fa.rel.len().cmp(&fb.rel.len()))); + hits.truncate(limit); + hits +} + +fn file_item(file: &IndexedFile, line: Option, column: Option) -> Item { + let mut item = Item::new( + file.name(), + CommandKind::OpenFile { + path: file.path.clone(), + line, + column, + }, + ); + if !file.dir().is_empty() { + item = item.with_subtitle(file.dir()); + } + if let Some(line) = line { + item = item.with_note(t_fmt( + L10nKey::SearchFilesGoToLine, + &[("line", &line.to_string())], + )); + } + item +} + +pub(super) struct Files<'a>(pub &'a FileList); + +impl Source for Files<'_> { + fn tab(&self) -> SearchTab { + SearchTab::Files + } + + fn browse(&self, _cx: &App) -> Vec
{ + let FileList::Ready(index) = self.0 else { + return Vec::new(); + }; + let rows: Vec = index + .files + .iter() + .take(BROWSE_ROWS) + .map(|f| Row::Item(file_item(f, None, None))) + .collect(); + if rows.is_empty() { + return Vec::new(); + } + // A walk that hit its cap left the deepest files out, and a search + // that cannot find one of them should not look like a missing file. + let title = index.capped.then(|| { + t_fmt( + L10nKey::SearchFilesCapped, + &[("count", &index.files.len().to_string())], + ) + .into() + }); + vec![Section { title, rows }] + } + + fn highlights(&self, _cx: &App) -> Vec { + Vec::new() + } + + /// A sample of the project is not an answer to anything, and on the All + /// tab it would push the tabs you were just in down the page. + fn on_the_empty_all_tab(&self) -> bool { + false + } + + fn search(&self, query: &str, _cx: &App) -> Vec<(i32, Item)> { + let FileList::Ready(index) = self.0 else { + return Vec::new(); + }; + let q = parse_query(query); + rank(index, q.needle, SEARCH_ROWS) + .into_iter() + .map(|(score, f)| (score, file_item(f, q.line, q.column))) + .collect() + } +} + +/// Which folders a walk is of: the host, and the directories the active tab's +/// panes are in. The walk resolves them to their repositories itself, on the +/// host, so the key is what the UI thread can know without asking. +#[derive(Clone, Debug, PartialEq, Eq)] +struct IndexKey { + host: HostId, + cwds: Vec, +} + +struct Walked { + key: IndexKey, + list: FileList, + at: Instant, +} + +/// The window's file index, kept between openings of the search. +#[derive(Default)] +pub(crate) struct FileIndexStore { + last: Option, + /// The walk in flight, if any: its generation and what it is walking. + /// A walk that lands after a newer one started is dropped. + walking: Option<(u64, IndexKey)>, + generation: u64, +} + +/// Walks the project the panes in `cwds` are in, on the host that has it. +/// Runs off the UI thread. +fn walk(h: &dyn Host, cwds: &[PathBuf], home: Option<&Path>) -> FileList { + let resolved = cwds + .iter() + .map(|cwd| { + h.repo_root(cwd) + .ok() + .flatten() + .unwrap_or_else(|| cwd.clone()) + }) + .collect(); + let roots = project_roots(resolved, home); + if roots.is_empty() { + return FileList::NoRoots; + } + // Hidden and gitignored entries stay out, as they do in the tree by + // default: that is what keeps `.git`, `target/` and `node_modules/` from + // filling the cap before the sources. The empty query matches every name. + match h.search(&roots, "", INDEX_LIMIT, INDEX_MAX_DIRS, false) { + Ok(hits) => { + let capped = hits.len() >= INDEX_LIMIT; + let files = hits + .into_iter() + .filter(|hit| !hit.is_dir) + .map(|hit| hit.path) + .collect(); + FileList::Ready(Arc::new(build_index(&roots, files, capped))) + } + Err(e) => { + log::warn!("quick open: walking {roots:?}: {e}"); + FileList::Failed + } + } +} + +impl Tty7App { + /// Search Everywhere on its Files tab — or closed, when that is what it + /// is already showing, so the chord that opens it also puts it away. + pub(crate) fn quick_open_file(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(view) = self.search.clone() { + if view.read(cx).tab() == SearchTab::Files { + self.close_search(window, cx); + } else { + view.update(cx, |view, cx| { + view.set_tab(SearchTab::Files, None, window, cx) + }); + } + return; + } + self.open_search(SearchTab::Files, "", window, cx); + } + + /// Opens a row of the Files tab, and has the tree follow along the way a + /// file link in the grid does. + pub(crate) fn open_indexed_file( + &mut self, + path: &Path, + line: Option, + column: Option, + window: &mut Window, + cx: &mut Context, + ) { + self.open_file_in_editor_at(path, line, column, window, cx); + self.file_tree_reveal_path(path, cx); + } + + fn file_index_key(&self, cx: &App) -> IndexKey { + let host = self.spawn_host(cx); + let mut cwds: Vec = self + .tabs + .get(self.active) + .map(|tab| tab.pane.terminals()) + .unwrap_or_default() + .iter() + .filter(|leaf| leaf.read(cx).host_id() == host) + // `files_cwd`, for the reason the Files panel roots itself there: + // it follows an agent into its worktree, and it spells a WSL + // pane's directory the way the host can read it. + .filter_map(|leaf| leaf.read(cx).files_cwd()) + .collect(); + cwds.sort(); + cwds.dedup(); + IndexKey { host, cwds } + } + + /// What the Files tab opens on: the last walk, if it was of the folders + /// the panes are in now. + pub(crate) fn file_list_now(&self, cx: &App) -> FileList { + let key = self.file_index_key(cx); + if key.cwds.is_empty() { + return FileList::NoRoots; + } + match &self.file_tree.quick_open.last { + Some(last) if last.key == key => last.list.clone(), + _ => FileList::Indexing, + } + } + + /// Walks the project again, unless a walk of it just landed or is + /// already on its way, and hands the result to the open search. + pub(crate) fn refresh_file_index(&mut self, window: &mut Window, cx: &mut Context) { + let key = self.file_index_key(cx); + if key.cwds.is_empty() { + return; + } + let store = &self.file_tree.quick_open; + if store.walking.as_ref().is_some_and(|(_, k)| *k == key) { + return; + } + if store + .last + .as_ref() + .is_some_and(|l| l.key == key && l.at.elapsed() < REVALIDATE_AFTER) + { + return; + } + let Some(host) = self.active_host(cx) else { + return; + }; + // Only this computer's home is known here, and it is the common case: + // a fresh local tab starts there. + let home = key + .host + .is_local() + .then(|| std::env::var_os("HOME").map(PathBuf::from)) + .flatten(); + let store = &mut self.file_tree.quick_open; + store.generation += 1; + let generation = store.generation; + store.walking = Some((generation, key.clone())); + let cwds = key.cwds.clone(); + HostOps::run_in( + host, + window, + cx, + move |h| walk(h, &cwds, home.as_deref()), + move |app, list, window, cx| { + let store = &mut app.file_tree.quick_open; + if store.walking.as_ref().map(|(g, _)| *g) != Some(generation) { + return; + } + store.walking = None; + // A revalidation that failed keeps the list it was checking: + // yesterday's files are still mostly where they were. + let keep_old = matches!(list, FileList::Failed) + && store + .last + .as_ref() + .is_some_and(|l| l.key == key && matches!(l.list, FileList::Ready(_))); + if !keep_old { + store.last = Some(Walked { + key: key.clone(), + list, + at: Instant::now(), + }); + } + if app.file_index_key(cx) != key { + return; + } + let list = app.file_list_now(cx); + if let Some(view) = app.search.clone() { + view.update(cx, |view, cx| view.set_files(list, window, cx)); + } + }, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn index(paths: &[&str]) -> FileIndex { + build_index( + &[PathBuf::from("/repo")], + paths + .iter() + .map(|p| PathBuf::from(format!("/repo/{p}"))) + .collect(), + false, + ) + } + + fn ranked(index: &FileIndex, query: &str) -> Vec { + rank(index, parse_query(query).needle, 50) + .into_iter() + .map(|(_, f)| f.rel.clone()) + .collect() + } + + #[test] + fn a_query_can_name_a_line_and_a_column() { + let q = |s| parse_query(s); + assert_eq!( + q("main.rs"), + FileQuery { + needle: "main.rs", + line: None, + column: None + } + ); + assert_eq!( + q("main.rs:120"), + FileQuery { + needle: "main.rs", + line: Some(120), + column: None + } + ); + assert_eq!( + q("src/main.rs:120:7"), + FileQuery { + needle: "src/main.rs", + line: Some(120), + column: Some(7) + } + ); + // Half typed: the colon is on its way to a number. + assert_eq!(q("main.rs:").needle, "main.rs"); + assert_eq!(q("main.rs:12:").line, Some(12)); + // Not a number, so part of the name. + assert_eq!(q("std::fs").needle, "std::fs"); + assert_eq!(q("a:b12").needle, "a:b12"); + // A bare number is a name to look for, not a line in nothing. + assert_eq!(q("404").needle, "404"); + assert_eq!(q(":12").needle, ":12"); + // Line 0 does not exist. + assert_eq!(q("x.rs:0").line, None); + assert_eq!(q(" x.rs:3 ").line, Some(3)); + } + + #[test] + fn a_match_on_the_name_beats_the_same_letters_in_the_path() { + let idx = index(&["apps/web/index.ts", "src/app.rs", "src/ui/app.rs"]); + let got = ranked(&idx, "app"); + assert_eq!( + &got[..2], + ["src/app.rs", "src/ui/app.rs"], + "the shorter of two equal name hits leads" + ); + assert_eq!(got[2], "apps/web/index.ts"); + } + + #[test] + fn an_exact_name_outranks_a_longer_one() { + let idx = index(&["src/ui/main_window.rs", "src/main.rs"]); + assert_eq!(ranked(&idx, "main.rs")[0], "src/main.rs"); + } + + #[test] + fn a_path_query_finds_files_by_their_directories() { + let idx = index(&["src/ui/search/view.rs", "src/ui/view.rs", "docs/view.md"]); + let got = ranked(&idx, "search/view"); + assert_eq!(got, ["src/ui/search/view.rs"]); + let got = ranked(&idx, "ui view"); + assert_eq!(got.len(), 2, "whitespace is ignored, as everywhere else"); + } + + #[test] + fn nothing_that_is_not_a_subsequence_matches() { + let idx = index(&["src/main.rs"]); + assert!(ranked(&idx, "zzz").is_empty()); + assert!(ranked(&idx, "").is_empty(), "an empty query ranks nothing"); + } + + #[test] + fn rows_split_into_a_name_and_the_directory_it_is_in() { + let idx = index(&["src/ui/app.rs", "README.md"]); + assert_eq!(idx.files[0].name(), "app.rs"); + assert_eq!(idx.files[0].dir(), "src/ui"); + assert_eq!(idx.files[1].name(), "README.md"); + assert_eq!(idx.files[1].dir(), ""); + } + + #[test] + fn two_roots_are_told_apart_by_name() { + let idx = build_index( + &[PathBuf::from("/work/api"), PathBuf::from("/work/web")], + vec![ + PathBuf::from("/work/api/src/main.rs"), + PathBuf::from("/work/web/src/main.rs"), + // A sibling whose name merely starts with a root's is not in it. + PathBuf::from("/work/api-old/x.rs"), + ], + false, + ); + let rels: Vec<_> = idx.files.iter().map(|f| f.rel.as_str()).collect(); + assert_eq!(rels, ["api/src/main.rs", "web/src/main.rs"]); + } + + #[test] + fn a_windows_path_splits_on_its_own_separator() { + let idx = build_index( + &[PathBuf::from(r"C:\repo")], + vec![PathBuf::from(r"C:\repo\src\lib.rs")], + false, + ); + assert_eq!(idx.files[0].rel, r"src\lib.rs"); + assert_eq!(idx.files[0].name(), "lib.rs"); + assert_eq!(idx.files[0].dir(), "src"); + } + + #[test] + fn home_and_the_filesystem_root_are_not_projects() { + let home = PathBuf::from("/home/me"); + let roots = project_roots( + vec![ + home.clone(), + PathBuf::from("/"), + PathBuf::from("/home/me/repo"), + PathBuf::from("/home/me/repo"), + PathBuf::from("/home/me/repo/crates/core"), + PathBuf::from("/srv/other"), + ], + Some(&home), + ); + assert_eq!( + roots, + [PathBuf::from("/home/me/repo"), PathBuf::from("/srv/other")], + "home and / go, a duplicate goes, and a folder inside another is covered by it" + ); + assert!(project_roots(vec![home.clone()], Some(&home)).is_empty()); + } + + /// The walk goes through the host, from the pane's directory up to its + /// repository, and leaves out what the tree hides by default. + #[test] + fn the_walk_indexes_the_repository_without_what_git_ignores() { + let host = tty7_core::host::local::LocalHost::new(); + let tmp = std::env::temp_dir().join(format!("tty7-quick-open-{}", std::process::id())); + let _ = host.remove(&tmp, true); + host.create_dir(&tmp.join(".git"), true).unwrap(); + host.create_dir(&tmp.join("src/ui"), true).unwrap(); + host.create_dir(&tmp.join("target/debug"), true).unwrap(); + host.write_file(&tmp.join(".gitignore"), b"target/\n") + .unwrap(); + host.write_file(&tmp.join(".git/HEAD"), b"").unwrap(); + host.write_file(&tmp.join("target/debug/out.rs"), b"") + .unwrap(); + host.write_file(&tmp.join("README.md"), b"").unwrap(); + host.write_file(&tmp.join("src/ui/app.rs"), b"").unwrap(); + + // From a subdirectory: the walk is of the whole repository. + let FileList::Ready(idx) = walk(&*host, &[tmp.join("src")], None) else { + panic!("the walk failed"); + }; + let mut rels: Vec<_> = idx.files.iter().map(|f| f.rel.replace('\\', "/")).collect(); + rels.sort(); + assert_eq!(rels, ["README.md", "src/ui/app.rs"]); + assert!(!idx.capped); + + assert!(matches!( + walk(&*host, std::slice::from_ref(&tmp), Some(&tmp)), + FileList::NoRoots + )); + let _ = host.remove(&tmp, true); + } + + #[test] + fn a_long_index_is_ranked_quickly_enough_to_type_into() { + let paths: Vec = (0..INDEX_LIMIT) + .map(|i| format!("crates/c{}/src/module_{i}/file_{i}.rs", i % 97)) + .collect(); + let refs: Vec<&str> = paths.iter().map(String::as_str).collect(); + let idx = index(&refs); + let started = Instant::now(); + let got = rank(&idx, "file_4242", 20); + assert_eq!(got[0].1.name(), "file_4242.rs"); + // Generous, so a loaded CI box does not flake; the point is that a + // full index is not seconds of work per keystroke. + assert!( + started.elapsed() < Duration::from_secs(2), + "ranking {INDEX_LIMIT} files took {:?}", + started.elapsed() + ); + } +} diff --git a/src/ui/search/mod.rs b/src/ui/search/mod.rs index 309cafb4..13508e1e 100644 --- a/src/ui/search/mod.rs +++ b/src/ui/search/mod.rs @@ -1,18 +1,21 @@ -//! Search Everywhere: one modal over everything the app can find — actions, -//! terminals, hosts — each in a tab of its own, and all of them at once in -//! the All tab. +//! Search Everywhere: one modal over everything the app can find — files, +//! actions, terminals, hosts — each in a tab of its own, and all of them at +//! once in the All tab. //! //! - [`command`]: what a row runs ([`CommandKind`]) and the rows themselves. //! - [`sources`]: what each tab holds and how it ranks against a query. +//! - [`files`]: the Files tab — the project's file index and quick open. //! - [`score`]: the one fuzzy scorer every tab shares. //! - [`view`]: the modal — the tab row, the list, the theme picker. mod command; +mod files; mod score; mod sources; mod view; pub(crate) use command::{Avatar, ChromeState, CommandGroup, CommandKind, Item}; +pub(crate) use files::{FileIndexStore, FileList}; pub(crate) use score::fuzzy_score; pub(crate) use sources::{Catalog, host_items}; pub(crate) use view::{KEY_CONTEXT, SearchEvent, SearchView}; @@ -22,6 +25,7 @@ use crate::ui::i18n::{L10nKey, t}; #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum SearchTab { All, + Files, Actions, Terminals, Sessions, @@ -30,8 +34,12 @@ pub(crate) enum SearchTab { impl SearchTab { /// The tab row, left to right, and the order Tab walks it. - pub(crate) const ORDER: [SearchTab; 5] = [ + /// + /// Files right after All: it is the tab its own chord opens, and what + /// VS Code's ⌘P taught people to expect first. + pub(crate) const ORDER: [SearchTab; 6] = [ SearchTab::All, + SearchTab::Files, SearchTab::Actions, SearchTab::Terminals, SearchTab::Sessions, @@ -41,6 +49,7 @@ impl SearchTab { pub(crate) fn title(self) -> &'static str { t(match self { SearchTab::All => L10nKey::SearchTabAll, + SearchTab::Files => L10nKey::SearchTabFiles, SearchTab::Actions => L10nKey::SearchTabActions, SearchTab::Terminals => L10nKey::SearchTabTerminals, SearchTab::Sessions => L10nKey::SearchTabSessions, @@ -51,6 +60,7 @@ impl SearchTab { pub(crate) fn placeholder(self) -> &'static str { t(match self { SearchTab::All => L10nKey::SearchPlaceholderAll, + SearchTab::Files => L10nKey::SearchPlaceholderFiles, SearchTab::Actions => L10nKey::SearchPlaceholderActions, SearchTab::Terminals => L10nKey::SearchPlaceholderTerminals, SearchTab::Sessions => L10nKey::SearchPlaceholderSessions, @@ -76,7 +86,8 @@ mod tests { #[test] fn tab_steps_wrap_both_ways() { - assert_eq!(SearchTab::All.step(true), SearchTab::Actions); + assert_eq!(SearchTab::All.step(true), SearchTab::Files); + assert_eq!(SearchTab::Files.step(true), SearchTab::Actions); assert_eq!(SearchTab::Hosts.step(true), SearchTab::All); assert_eq!(SearchTab::All.step(false), SearchTab::Hosts); assert_eq!(SearchTab::Terminals.step(false), SearchTab::Actions); diff --git a/src/ui/search/sources.rs b/src/ui/search/sources.rs index af71211e..804fc1c5 100644 --- a/src/ui/search/sources.rs +++ b/src/ui/search/sources.rs @@ -8,6 +8,7 @@ use gpui::{App, SharedString}; use super::SearchTab; use super::command::{CommandGroup, CommandKind, Item}; +use super::files::{FileList, Files}; use super::score::{frecency_bonus, item_score}; use crate::core::config::Config; use crate::core::ssh_profile::parse_quick_connect; @@ -57,6 +58,12 @@ pub(crate) trait Source { /// typed. Empty is fine: the tab then shows only once a query finds it. fn highlights(&self, cx: &App) -> Vec; + /// Whether the All tab shows this tab before anything is typed. Every tab + /// with anything in it does, unless what it would show is only filler. + fn on_the_empty_all_tab(&self) -> bool { + true + } + /// Rows answering `query`, best first, each with the score that put it /// there. The All tab compares these scores across tabs, so every source /// scores with [`item_score`] and only nudges it. @@ -80,6 +87,9 @@ pub(crate) struct Catalog { pub sessions: Vec, /// How many of `sessions` lead the list because they ran here. pub sessions_here: usize, + /// The project's files, from the window's last walk of it. A fresh walk + /// may land after the search opens (`Tty7App::refresh_file_index`). + pub files: FileList, } impl Catalog { @@ -98,6 +108,7 @@ impl Catalog { hosts, sessions: Vec::new(), sessions_here: 0, + files: FileList::default(), } } @@ -111,6 +122,7 @@ impl Catalog { here: self.sessions_here, })), SearchTab::Hosts => Some(Box::new(Hosts(&self.hosts))), + SearchTab::Files => Some(Box::new(Files(&self.files))), } } @@ -138,7 +150,7 @@ impl Catalog { if query.is_empty() { // Terminals first: before anything is typed the likeliest thing // wanted is the tab you were just in. - let mut sources: Vec<_> = tabs.collect(); + let mut sources: Vec<_> = tabs.filter(|s| s.on_the_empty_all_tab()).collect(); sources.sort_by_key(|s| s.tab() != SearchTab::Terminals); return sources .into_iter() @@ -681,6 +693,70 @@ mod tests { }); } + fn with_files(paths: &[&str]) -> Catalog { + let mut catalog = Catalog::new( + vec![Item::new("Split Right", CommandKind::SplitRight)], + vec![terminal("main shell", "here")], + Vec::new(), + ); + let root = std::path::PathBuf::from("/repo"); + let files = paths.iter().map(|p| root.join(p)).collect(); + catalog.files = FileList::Ready(std::sync::Arc::new(super::super::files::build_index( + &[root], + files, + false, + ))); + catalog + } + + /// Files join the All tab once something is typed, under their own + /// header, and stay out of it before — a sample of the project is not an + /// answer to anything. + #[gpui::test] + fn files_are_on_the_all_tab_only_once_a_query_finds_them(cx: &mut TestAppContext) { + with_config(cx); + let catalog = with_files(&["src/main.rs", "src/ui/app.rs"]); + cx.update(|cx| { + // No dot in it: `main.rs` also reads as a host name, and a typed + // address leads the All tab whatever else matches. + let sections = catalog.sections(SearchTab::All, "ui/app", cx); + assert_eq!(sections[0].title.as_deref(), Some("Files")); + assert_eq!(row_titles(§ions[0]), vec!["app.rs"]); + + let empty = catalog.sections(SearchTab::All, "", cx); + let headers: Vec<_> = empty.iter().filter_map(|s| s.title.clone()).collect(); + assert!( + !headers.iter().any(|h| h == "Files"), + "no files before a query: {headers:?}" + ); + + // The tab itself shows some before anything is typed. + let own = catalog.sections(SearchTab::Files, "", cx); + assert_eq!(own[0].rows.len(), 2); + }); + } + + /// `name:line:column` opens the file there, and says so on the row. + #[gpui::test] + fn a_file_query_with_a_line_opens_on_that_line(cx: &mut TestAppContext) { + with_config(cx); + let catalog = with_files(&["src/main.rs"]); + cx.update(|cx| { + let sections = catalog.sections(SearchTab::Files, "main.rs:120:4", cx); + let item = sections[0].rows[0].item().expect("a file row"); + assert_eq!( + item.kind, + CommandKind::OpenFile { + path: "/repo/src/main.rs".into(), + line: Some(120), + column: Some(4), + } + ); + assert_eq!(item.subtitle.as_deref(), Some("src")); + assert_eq!(item.note.as_deref(), Some("line 120")); + }); + } + fn session(title: &str, section: &str) -> Item { Item::new( title, diff --git a/src/ui/search/view.rs b/src/ui/search/view.rs index 238454b7..4b9c55c8 100644 --- a/src/ui/search/view.rs +++ b/src/ui/search/view.rs @@ -12,9 +12,9 @@ use gpui_component::{ v_flex, }; -use super::SearchTab; use super::command::{CommandKind, Item}; use super::sources::{Catalog, Row, Section, plain}; +use super::{FileList, SearchTab}; use crate::core::actions::{SearchNextTab, SearchPrevTab}; use crate::ui::dialog::{CARD_RADIUS, FOOTER_H, KEYCAP, keycap}; use crate::ui::i18n::{L10nKey, t, t_fmt}; @@ -234,7 +234,22 @@ impl ListDelegate for SearchDelegate { ) -> impl IntoElement { // The SSH hint only where typing user@host would actually connect. A // theme picker with no matches teaching SSH is a crossed wire (#602). + let mut headline = t(L10nKey::SearchNoResults); let hint = match self.scope { + // An empty Files tab is rarely "nothing matches": say which of the + // other reasons it is, since each has a different way out. + Scope::Tab(SearchTab::Files) => match &self.catalog.files { + FileList::NoRoots => { + headline = t(L10nKey::SearchFilesNoRoots); + t(L10nKey::SearchFilesNoRootsHint) + } + FileList::Indexing => { + headline = t(L10nKey::SearchFilesIndexing); + "" + } + FileList::Failed => t(L10nKey::SearchFilesFailed), + FileList::Ready(_) => t(L10nKey::PaletteTryDifferentSearch), + }, Scope::Tab(SearchTab::All | SearchTab::Hosts) => t(L10nKey::ConnectSshHint), Scope::Tab(SearchTab::Sessions) if self.query.trim().is_empty() => { t(L10nKey::SearchSessionsEmptyHint) @@ -251,13 +266,15 @@ impl ListDelegate for SearchDelegate { .items_center() .text_size(rems(ROW_TEXT)) .text_color(theme.foreground) - .child(t(L10nKey::SearchNoResults)) - .child( - div() - .text_size(rems(ROW_META)) - .text_color(theme.muted_foreground) - .child(hint), - ) + .child(headline) + .when(!hint.is_empty(), |d| { + d.child( + div() + .text_size(rems(ROW_META)) + .text_color(theme.muted_foreground) + .child(hint), + ) + }) } fn render_item( @@ -453,9 +470,7 @@ impl SearchView { } /// The Sessions tab's rows, arrived from a scan that finished after the - /// search opened. The highlight stays on the row it was on when that row - /// is still there, so a list that fills in under the cursor does not - /// move what Return runs. + /// search opened. pub(crate) fn set_sessions( &mut self, sessions: Vec, @@ -464,9 +479,38 @@ impl SearchView { cx: &mut Context, ) { self.sessions_landed += 1; + self.update_catalog( + |catalog| { + catalog.sessions = sessions; + catalog.sessions_here = here; + }, + window, + cx, + ); + } + + /// The Files tab's list, arrived from a walk that finished after the + /// search opened. + pub(crate) fn set_files( + &mut self, + files: FileList, + window: &mut Window, + cx: &mut Context, + ) { + self.update_catalog(|catalog| catalog.files = files, window, cx); + } + + /// Changes part of the catalog under an open list. The highlight stays on + /// the row it was on when that row is still there, so a list that fills in + /// under the cursor does not move what Return runs. + fn update_catalog( + &mut self, + change: impl FnOnce(&mut Catalog), + window: &mut Window, + cx: &mut Context, + ) { let mut catalog = (*self.catalog).clone(); - catalog.sessions = sessions; - catalog.sessions_here = here; + change(&mut catalog); self.catalog = Rc::new(catalog); if self.in_sub_list() { return; @@ -484,6 +528,11 @@ impl SearchView { cx.notify(); } + /// The tab showing — or, in a row's own list, the one Escape returns to. + pub(crate) fn tab(&self) -> SearchTab { + self.tab + } + fn step_tab(&mut self, forward: bool, window: &mut Window, cx: &mut Context) { self.set_tab(self.tab.step(forward), None, window, cx); } @@ -601,6 +650,9 @@ impl SearchView { CommandKind::SearchHosts => { self.set_tab(SearchTab::Hosts, Some(""), window, cx) } + CommandKind::QuickOpenFile => { + self.set_tab(SearchTab::Files, Some(""), window, cx) + } kind => cx.emit(SearchEvent::Confirm(kind)), }, None => cx.emit(SearchEvent::Dismiss), @@ -930,7 +982,8 @@ mod tests { vcx.run_until_parked(); let view = open(&app, &mut vcx); - vcx.simulate_keystrokes("tab"); + // Past Files, which comes right after All. + vcx.simulate_keystrokes("tab tab"); vcx.run_until_parked(); view.read_with(&vcx, |view, cx| { assert_eq!(view.tab, SearchTab::Actions); @@ -939,7 +992,7 @@ mod tests { assert_eq!(first_kind(&view, &mut vcx), Some(CommandKind::SplitRight)); // Backwards, and round the end. - vcx.simulate_keystrokes("shift-tab shift-tab"); + vcx.simulate_keystrokes("shift-tab shift-tab shift-tab"); vcx.run_until_parked(); view.read_with(&vcx, |view, _| assert_eq!(view.tab, SearchTab::Hosts)); assert!( @@ -999,6 +1052,81 @@ mod tests { }); } + /// The quick-open chord lands on the Files tab, from nowhere or from + /// another tab, and puts the search away when it is already there. + #[gpui::test] + fn quick_open_goes_to_the_files_tab_and_back_out(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + crate::ui::i18n::set_locale("en"); + app.update_in(&mut vcx, |app, window, cx| app.quick_open_file(window, cx)); + vcx.run_until_parked(); + let view = open(&app, &mut vcx); + view.read_with(&vcx, |view, _| assert_eq!(view.tab, SearchTab::Files)); + + view.update_in(&mut vcx, |view, window, cx| { + view.set_tab(SearchTab::Actions, None, window, cx) + }); + app.update_in(&mut vcx, |app, window, cx| app.quick_open_file(window, cx)); + vcx.run_until_parked(); + view.read_with(&vcx, |view, _| assert_eq!(view.tab, SearchTab::Files)); + + app.update_in(&mut vcx, |app, window, cx| app.quick_open_file(window, cx)); + vcx.run_until_parked(); + assert!(app.read_with(&vcx, |app, _| app.search.is_none())); + } + + /// The Actions tab's "Go to File…" row moves the search to its Files tab + /// rather than closing it, with whatever found the row cleared away. + #[gpui::test] + fn go_to_file_moves_to_an_empty_files_tab(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + crate::ui::i18n::set_locale("en"); + app.update_in(&mut vcx, |app, window, cx| { + app.open_search(SearchTab::Actions, "go-to-file", window, cx) + }); + vcx.run_until_parked(); + let view = open(&app, &mut vcx); + assert_eq!( + first_kind(&view, &mut vcx), + Some(CommandKind::QuickOpenFile) + ); + + vcx.simulate_keystrokes("enter"); + vcx.run_until_parked(); + view.read_with(&vcx, |view, cx| { + assert_eq!(view.tab, SearchTab::Files); + assert_eq!(view.list.read(cx).delegate().query, ""); + }); + } + + /// A walk that lands after the search opened fills the Files tab in place. + #[gpui::test] + fn a_file_list_that_lands_late_fills_the_tab(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + crate::ui::i18n::set_locale("en"); + app.update_in(&mut vcx, |app, window, cx| { + app.open_search(SearchTab::Files, "lib", window, cx) + }); + vcx.run_until_parked(); + let view = open(&app, &mut vcx); + assert_eq!(first_kind(&view, &mut vcx), None); + + let root = std::path::PathBuf::from("/repo"); + let index = super::super::files::build_index( + std::slice::from_ref(&root), + vec![root.join("src/lib.rs")], + false, + ); + view.update_in(&mut vcx, |view, window, cx| { + view.set_files(FileList::Ready(std::sync::Arc::new(index)), window, cx) + }); + vcx.run_until_parked(); + assert!(matches!( + first_kind(&view, &mut vcx), + Some(CommandKind::OpenFile { .. }) + )); + } + fn past_session(id: &str) -> Item { Item::new( format!("session {id}"), diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 6dff84ae..c9834ff1 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -85,6 +85,7 @@ pub(crate) fn set_menus(cx: &mut App) { ]), Menu::new(t(L10nKey::AppMenuView)).items([ MenuItem::action(t(L10nKey::AppMenuSearchEverywhere), TogglePalette), + MenuItem::action(t(L10nKey::CmdGoToFile), QuickOpenFile), MenuItem::separator(), MenuItem::action(t(L10nKey::AppMenuIncreaseFontSize), IncreaseFontSize), MenuItem::action(t(L10nKey::AppMenuDecreaseFontSize), DecreaseFontSize),