mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-11-06 08:15:35 +00:00
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:
@@ -73,6 +73,7 @@ actions!(
|
||||
DecreaseFontSize,
|
||||
ResetFontSize,
|
||||
TogglePalette,
|
||||
QuickOpenFile,
|
||||
ReopenClosedTab,
|
||||
ToggleMaximizePane,
|
||||
ToggleFullscreen,
|
||||
|
||||
@@ -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)
|
||||
}))
|
||||
|
||||
@@ -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
@@ -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
@@ -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 => {
|
||||
|
||||
@@ -178,6 +178,15 @@ l10n_keys! {
|
||||
SearchPlaceholderActions,
|
||||
SearchPlaceholderTerminals,
|
||||
SearchPlaceholderHosts,
|
||||
SearchTabFiles,
|
||||
SearchPlaceholderFiles,
|
||||
SearchFilesNoRoots,
|
||||
SearchFilesNoRootsHint,
|
||||
SearchFilesIndexing,
|
||||
SearchFilesFailed,
|
||||
SearchFilesGoToLine,
|
||||
SearchFilesCapped,
|
||||
CmdGoToFile,
|
||||
SearchTabSessions,
|
||||
SearchPlaceholderSessions,
|
||||
SearchSessionsEmptyHint,
|
||||
|
||||
+10
-1
@@ -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 的历史会话。",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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(§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,
|
||||
|
||||
+143
-15
@@ -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}"),
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user