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.
This commit is contained in:
l0ng-ai
2026-09-27 13:44:26 +02:00
parent 6ba407b06c
commit e780feacf2
14 changed files with 1064 additions and 24 deletions
+1
View File
@@ -73,6 +73,7 @@ actions!(
DecreaseFontSize,
ResetFontSize,
TogglePalette,
QuickOpenFile,
ReopenClosedTab,
ToggleMaximizePane,
ToggleFullscreen,
+9
View File
@@ -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)
}))
+5
View File
@@ -207,6 +207,10 @@ pub(crate) struct FileTreeState {
repo_roots: ByHost<PathBuf, PathBuf>,
repo_root_loads: InFlight<DirKey>,
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<TreeEdit>,
editing_subs: Vec<Subscription>,
@@ -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(),
+12 -1
View File
@@ -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 => {
+14 -1
View File
@@ -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 => {
+9
View File
@@ -178,6 +178,15 @@ l10n_keys! {
SearchPlaceholderActions,
SearchPlaceholderTerminals,
SearchPlaceholderHosts,
SearchTabFiles,
SearchPlaceholderFiles,
SearchFilesNoRoots,
SearchFilesNoRootsHint,
SearchFilesIndexing,
SearchFilesFailed,
SearchFilesGoToLine,
SearchFilesCapped,
CmdGoToFile,
SearchTabSessions,
SearchPlaceholderSessions,
SearchSessionsEmptyHint,
+10 -1
View File
@@ -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 的历史会话。",
+10
View File
@@ -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<KeyBinding> {
"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),
+14
View File
@@ -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<u32>,
column: Option<u32>,
},
/// 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
+743
View File
@@ -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<IndexedFile>,
/// 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<FileIndex>),
}
/// 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<u32>,
pub column: Option<u32>,
}
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<u32> = 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::<u32>() 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<PathBuf>, home: Option<&Path>) -> Vec<PathBuf> {
let mut roots: Vec<PathBuf> = 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<PathBuf>, 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<i32> {
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<char> = 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<u32>, column: Option<u32>) -> 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<Section> {
let FileList::Ready(index) = self.0 else {
return Vec::new();
};
let rows: Vec<Row> = 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<Item> {
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<PathBuf>,
}
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<Walked>,
/// 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<Self>) {
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<u32>,
column: Option<u32>,
window: &mut Window,
cx: &mut Context<Self>,
) {
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<PathBuf> = 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<Self>) {
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<String> {
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<String> = (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()
);
}
}
+16 -5
View File
@@ -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);
+77 -1
View File
@@ -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<Item>;
/// 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<Item>,
/// 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(&sections[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,
+143 -15
View File
@@ -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<Item>,
@@ -464,9 +479,38 @@ impl SearchView {
cx: &mut Context<Self>,
) {
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>,
) {
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<Self>,
) {
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>) {
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}"),
+1
View File
@@ -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),