fix(editor): stop losing edits, share buffers, add file strip, quick open and go to line (#984)

* fix(host): save local files atomically via a temp file and rename

LocalHost::write_file truncated the target in place, so a crash, a full
disk or a killed process mid-save destroyed the user's file. It now
writes a hidden sibling temp file, syncs it, keeps the old file's mode
and renames it over the target, removing the temp file on any error.

It still writes in place where a rename would change something visible:
a non-regular target (symlink, directory, FIFO), a read-only file, and on
Unix a hard-linked file or one owned by another user, or when the temp
file cannot be created (e.g. a read-only directory).

* feat(editor): add editor_text for encodings, line endings, indentation and EditorConfig

A pure module the code editor will use when loading and saving files:
decode detects BOMs, binary files, UTF-8, GB18030 and a lossless
Windows-1252 fallback and normalises CRLF; encode restores the exact
bytes and names the first unrepresentable character; detect_indent
infers tabs or a 2/4/8 space width with language defaults; and
editorconfig_for resolves .editorconfig sections with save-time rules.

* feat(editor): share buffers across tabs, guard unsaved work, add a file strip

- One buffer per file per window; tabs list which buffers they show. The
  same file open in two tabs is no longer two diverging copies.
- Closing a tab, its last pane, the window, or quitting asks about unsaved
  files (Save / Cancel / Discard) instead of dropping them. Bulk closes skip
  tabs with unsaved files; a tab that vanishes any other way hands its
  unsaved buffers to the tab in front.
- File tree rename/delete now retarget or flag the open buffer, so a save
  no longer recreates the old path.
- Saves check the file's mtime first and ask before overwriting a change
  made elsewhere; this is the only detection SFTP buffers get.
- Dirty is a comparison with the saved text, so undoing back clears it.
- Reloads replace only the changed span as an ordinary edit, keeping undo.
- Load/save go through editor_text: encoding, BOM and CRLF round-trip,
  indentation is detected, .editorconfig is honoured.
- Header shows a strip of open files; New File, Save As (native panel
  locally, a path bar remotely), Go to Line (Ctrl+G), and the status bar
  shows indentation, encoding and a clickable line ending.
- Open files are remembered per tab across restarts.

* 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.

* chore(editor): allowlist the editor session file, tidy lints

* fix(editor): keep restored file order, drop stale close waits, carry files through tab merges

- Background arrivals (restore, merge, rescue) append to the strip in order
  instead of inserting beside the active file, which reversed them.
- A cancelled Save As, a dismissed path bar, or a dropped buffer cancels any
  close that was waiting on that save.
- Merging a tab into another carries its open files along.
- A shell exiting closes its tab without a prompt it could not honour;
  unsaved buffers move to the tab in front.
- Tabs rebuilt under the same id (server restart) restore their files.

* fix(host): only fall back to an in-place write when the rename is refused

On Windows every failure of the atomic save fell back to fs::write,
including a failure while staging the temp file. A full disk would then
truncate the original in place, the very loss the temp file prevents.
Staging errors now return as-is; only a refused rename (a file held open
elsewhere) takes the in-place path.
This commit is contained in:
l0ng-ai
2026-09-28 00:32:18 +08:00
committed by GitHub
parent b063ba97a0
commit bf5149bea0
23 changed files with 5268 additions and 504 deletions
+4
View File
@@ -53,6 +53,10 @@ src/ui/presets.rs|std::fs::
src/ui/presets.rs|.is_absolute()
src/ui/app.rs|std::fs::create_dir_all
# The editor's record of which files each tab had open is app state under the
# local config dir, like the themes above — not a file in any workspace.
src/ui/editor_session.rs|std::fs::read
# Reading a private key off *this* machine to hash it into a keychain account
# (`core::keychain`). The key is the client's credential; the far side never
# sees the file, only the resulting auth.
Generated
+2
View File
@@ -9903,6 +9903,8 @@ dependencies = [
"anyhow",
"async_zip 0.0.19",
"core-foundation 0.10.0",
"encoding_rs",
"globset",
"gpui",
"gpui-component",
"gpui-component-assets",
+8
View File
@@ -77,6 +77,14 @@ tempfile = "3"
url = "2"
percent-encoding = "2"
# The code editor's file loading and saving (`ui::editor_text`). `encoding_rs`
# decodes legacy-encoded text (GB18030, Windows-1252) and writes it back in the
# same encoding, and `globset` matches `.editorconfig` section globs. Both are
# already in the tree (via gpui's HTTP stack and its `ignore` walker), so they
# pin no new code.
encoding_rs = "0.8.35"
globset = "0.4.20"
# SSH profile ids in the connection manager UI (`ui::ssh_connect`,
# `ui::settings`, the command palette). The profiles themselves — and the
# secret-free `CredentialRef` they carry — live in `tty7-core`, which `Config`
+309 -1
View File
@@ -255,9 +255,13 @@ impl Host for LocalHost {
crate::host::content_search::search(roots, query, limits)
}
/// Replaces `p` with `bytes` atomically where it can, so a crash, a full
/// disk or a killed process halfway through a save leaves either the old
/// file or the new one and never a truncated mix — see [`write_replacing`]
/// for when it cannot and writes in place instead.
fn write_file(&self, p: &Path, bytes: &[u8]) -> io::Result<Meta> {
guard_off_ui();
fs::write(p, bytes)?;
write_replacing(p, bytes, &mut |f| io::Write::write_all(f, bytes))?;
self.stat(p)
}
@@ -348,6 +352,149 @@ impl Host for LocalHost {
}
}
/// Writes `bytes` to `p` by filling a hidden sibling temp file and renaming it
/// over `p`, which is what makes a save atomic: `fs::write` truncates first, so
/// anything that stops it before the last byte lands destroys the user's file.
///
/// A rename swaps in a new inode, though, and that is not always invisible.
/// Wherever it would change something the user can see, this writes in place
/// exactly as `fs::write` did and gives up atomicity instead:
///
/// - `p` is not a regular file. A symlink must keep pointing where it points
/// and have its target written, not be replaced by a plain file; a
/// directory or a FIFO has to fail or be written the way it always was.
/// - `p` is read-only. The rename only needs the directory to be writable, so
/// it would quietly overwrite a file its owner protected; writing in place
/// fails with the same `PermissionDenied` it always did.
/// - (Unix) `p` has more than one hard link. The other names would keep the
/// old content, while an in-place write reaches all of them.
/// - (Unix) `p` belongs to another user. The new file would belong to us, so
/// a root-run save of someone else's file would hand it to root.
/// - The temp file cannot be created, typically because the directory is not
/// writable while the file is. If the directory is missing altogether the
/// in-place write fails too, with the same `NotFound` as before.
///
/// `fill` writes the content into the temp file; it is a parameter only so the
/// tests can make it fail halfway.
fn write_replacing(
p: &Path,
bytes: &[u8],
fill: &mut dyn FnMut(&mut fs::File) -> io::Result<()>,
) -> io::Result<()> {
let existing = match fs::symlink_metadata(p) {
Ok(md) => Some(md),
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
Err(e) => return Err(e),
};
if existing
.as_ref()
.is_some_and(|md| !replace_is_invisible(md))
{
return fs::write(p, bytes);
}
let Some((tmp_path, mut tmp)) = create_temp_beside(p) else {
return fs::write(p, bytes);
};
// From here on every early return must take the temp file with it, or a
// failed save would litter the user's directory with `.tty7-….tmp` files.
let staged = (|| {
// A new file keeps the umask-derived mode it was created with, which
// is what `fs::write` would have given it; an existing one keeps its
// own.
if let Some(md) = &existing {
tmp.set_permissions(md.permissions())?;
}
fill(&mut tmp)?;
// Without this the rename can reach the disk before the data does,
// and a power cut leaves an empty file under the old name.
tmp.sync_all()
})();
drop(tmp);
// Staging failed — a full disk, say. The original is untouched and must
// stay that way: writing it in place now would truncate it into exactly
// the half-written file this function exists to prevent.
if let Err(e) = staged {
let _ = fs::remove_file(&tmp_path);
return Err(e);
}
if let Err(e) = fs::rename(&tmp_path, p) {
let _ = fs::remove_file(&tmp_path);
// Windows refuses to rename over a file another program holds open,
// where the in-place write it used to get succeeds, so there a failed
// swap is not yet a failed save. Only the swap: the content was just
// written out in full, so the disk has room for it.
#[cfg(not(unix))]
{
let _ = e;
return fs::write(p, bytes);
}
#[cfg(unix)]
return Err(e);
}
sync_parent_dir(p);
Ok(())
}
/// Whether swapping a new inode in for the file `md` describes would go
/// unnoticed — see [`write_replacing`] for why each of these matters.
fn replace_is_invisible(md: &fs::Metadata) -> bool {
if !md.file_type().is_file() || md.permissions().readonly() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
// SAFETY: `geteuid` takes nothing, touches no memory and cannot fail.
let euid = unsafe { libc::geteuid() };
// Being the owner, the owner's write bit is the one that decides
// whether an in-place write is allowed; `readonly` above only catches
// a file with no write bit at all.
md.nlink() == 1 && md.uid() == euid && md.mode() & 0o200 != 0
}
#[cfg(not(unix))]
{
true
}
}
/// Creates a fresh hidden file next to `p` to stage its new content in. It has
/// to be in the same directory, since a rename across filesystems is not
/// atomic (and fails outright), and it must be new, so two saves racing each
/// other — or a leftover from a crash — can never share one.
fn create_temp_beside(p: &Path) -> Option<(PathBuf, fs::File)> {
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let dir = p.parent()?;
let name = p.file_name()?.to_string_lossy();
for _ in 0..8 {
let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let tmp = dir.join(format!(".{name}.tty7-{}-{n}.tmp", std::process::id()));
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp)
{
Ok(f) => return Some((tmp, f)),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(_) => return None,
}
}
None
}
/// Flushes the directory entry the rename just changed, so the new name
/// survives a power cut too. Best effort: the content is already safe on disk,
/// and not every filesystem lets a directory be opened or synced.
fn sync_parent_dir(p: &Path) {
#[cfg(unix)]
if let Some(dir) = p.parent()
&& let Ok(d) = fs::File::open(dir)
{
let _ = d.sync_all();
}
#[cfg(not(unix))]
let _ = p;
}
#[derive(Default)]
struct WatchedDirs {
by_canonical: HashMap<PathBuf, PathBuf>,
@@ -708,4 +855,165 @@ mod tests {
"a `.gitignore` change seen by the watcher must drop the compiled matchers"
);
}
/// The names in `dir`, sorted — for spotting a temp file a save left behind.
fn names_in(dir: &Path) -> Vec<String> {
let mut v: Vec<String> = fs::read_dir(dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
v.sort();
v
}
#[test]
fn a_save_replaces_the_file_and_leaves_nothing_beside_it() {
let tmp = tempfile::TempDir::new().unwrap();
let f = tmp.path().join("doc.txt");
let h = LocalHost::new();
h.write_file(&f, b"first, and longer").unwrap();
#[cfg(unix)]
let before = std::os::unix::fs::MetadataExt::ino(&fs::metadata(&f).unwrap());
let meta = h.write_file(&f, b"second").unwrap();
assert_eq!(fs::read(&f).unwrap(), b"second");
assert_eq!(meta.len, 6);
assert_eq!(names_in(tmp.path()), vec!["doc.txt"]);
#[cfg(unix)]
assert_ne!(
std::os::unix::fs::MetadataExt::ino(&fs::metadata(&f).unwrap()),
before,
"the save must swap in a new file rather than truncate the old one"
);
}
#[test]
fn a_new_file_is_created_like_fs_write_would() {
let tmp = tempfile::TempDir::new().unwrap();
let f = tmp.path().join("fresh.txt");
let control = tmp.path().join("control.txt");
fs::write(&control, b"").unwrap();
LocalHost::new().write_file(&f, b"hello").unwrap();
assert_eq!(fs::read(&f).unwrap(), b"hello");
assert_eq!(
fs::metadata(&f).unwrap().permissions(),
fs::metadata(&control).unwrap().permissions(),
"a new file takes the umask's mode, as `fs::write` gives it"
);
assert_eq!(names_in(tmp.path()), vec!["control.txt", "fresh.txt"]);
}
#[test]
fn a_save_into_a_missing_directory_fails_and_creates_nothing() {
let tmp = tempfile::TempDir::new().unwrap();
let f = tmp.path().join("gone").join("doc.txt");
let err = LocalHost::new().write_file(&f, b"x").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::NotFound, "{err}");
assert!(names_in(tmp.path()).is_empty());
}
#[test]
fn a_failed_save_keeps_the_old_file_and_cleans_up() {
let tmp = tempfile::TempDir::new().unwrap();
let f = tmp.path().join("doc.txt");
fs::write(&f, b"precious").unwrap();
let err = write_replacing(&f, b"new content", &mut |file| {
io::Write::write_all(file, b"new")?;
Err(io::Error::other("disk full"))
})
.unwrap_err();
assert_eq!(err.to_string(), "disk full");
assert_eq!(fs::read(&f).unwrap(), b"precious");
assert_eq!(names_in(tmp.path()), vec!["doc.txt"]);
}
#[cfg(unix)]
#[test]
fn a_save_keeps_the_files_mode() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let f = tmp.path().join("run.sh");
fs::write(&f, b"#!/bin/sh\n").unwrap();
fs::set_permissions(&f, fs::Permissions::from_mode(0o750)).unwrap();
LocalHost::new()
.write_file(&f, b"#!/bin/sh\necho hi\n")
.unwrap();
assert_eq!(
fs::metadata(&f).unwrap().permissions().mode() & 0o7777,
0o750
);
assert_eq!(fs::read(&f).unwrap(), b"#!/bin/sh\necho hi\n");
}
#[cfg(unix)]
#[test]
fn a_hard_linked_file_is_written_in_place_so_every_name_sees_it() {
let tmp = tempfile::TempDir::new().unwrap();
let f = tmp.path().join("doc.txt");
let other = tmp.path().join("alias.txt");
fs::write(&f, b"old").unwrap();
fs::hard_link(&f, &other).unwrap();
LocalHost::new().write_file(&f, b"new").unwrap();
assert_eq!(fs::read(&f).unwrap(), b"new");
assert_eq!(fs::read(&other).unwrap(), b"new");
assert_eq!(names_in(tmp.path()), vec!["alias.txt", "doc.txt"]);
}
#[cfg(unix)]
#[test]
fn a_symlink_is_written_through_not_replaced() {
let tmp = tempfile::TempDir::new().unwrap();
let target = tmp.path().join("real.txt");
let link = tmp.path().join("link.txt");
fs::write(&target, b"old").unwrap();
std::os::unix::fs::symlink(&target, &link).unwrap();
LocalHost::new().write_file(&link, b"new").unwrap();
assert!(
fs::symlink_metadata(&link)
.unwrap()
.file_type()
.is_symlink()
);
assert_eq!(fs::read(&target).unwrap(), b"new");
assert_eq!(names_in(tmp.path()), vec!["link.txt", "real.txt"]);
}
#[cfg(unix)]
#[test]
fn a_read_only_file_still_refuses_the_save() {
use std::os::unix::fs::PermissionsExt;
// Root writes through any mode, so there is nothing to refuse.
// SAFETY: `geteuid` takes nothing and cannot fail.
if unsafe { libc::geteuid() } == 0 {
return;
}
let tmp = tempfile::TempDir::new().unwrap();
let f = tmp.path().join("locked.txt");
fs::write(&f, b"keep").unwrap();
fs::set_permissions(&f, fs::Permissions::from_mode(0o444)).unwrap();
let err = LocalHost::new().write_file(&f, b"clobber").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied, "{err}");
assert_eq!(fs::read(&f).unwrap(), b"keep");
assert_eq!(names_in(tmp.path()), vec!["locked.txt"]);
}
#[cfg(unix)]
#[test]
fn a_writable_file_in_a_read_only_directory_is_written_in_place() {
use std::os::unix::fs::PermissionsExt;
// SAFETY: `geteuid` takes nothing and cannot fail.
if unsafe { libc::geteuid() } == 0 {
return;
}
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("ro");
fs::create_dir(&dir).unwrap();
let f = dir.join("doc.txt");
fs::write(&f, b"old").unwrap();
fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap();
let wrote = LocalHost::new().write_file(&f, b"new");
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
wrote.unwrap();
assert_eq!(fs::read(&f).unwrap(), b"new");
assert_eq!(names_in(&dir), vec!["doc.txt"]);
}
}
+4
View File
@@ -73,6 +73,7 @@ actions!(
DecreaseFontSize,
ResetFontSize,
TogglePalette,
QuickOpenFile,
ReopenClosedTab,
ToggleMaximizePane,
ToggleFullscreen,
@@ -124,6 +125,9 @@ actions!(
ToggleDocumentPreview,
ToggleDocumentWrap,
EditorSave,
EditorSaveAs,
EditorGoToLine,
EditorNewFile,
OpenSshProfiles,
RestartSshSession,
SendTab,
+2 -3
View File
@@ -884,15 +884,14 @@ impl Tty7App {
if !code.visible {
return None;
}
let open = code.active_file()?;
// The file's host, not the window's. They are the same for everything
// the tree can open, but a buffer read over SFTP carries a path from
// another machine, and pairing it with this one's host would resolve
// it against a local repository that merely shares the path.
let host = open.host.id();
let (host, path) = self.editor_active_location()?;
let root = cx
.try_global::<crate::terminal::git_status::GitStatusCache>()?
.repo_root_for(host, open.path.parent()?)?;
.repo_root_for(host, path.parent()?)?;
Some((host, root.to_path_buf()))
}
+159 -10
View File
@@ -1648,10 +1648,25 @@ impl Tty7App {
.detach();
let weak_app = cx.weak_entity();
window.on_window_should_close(cx, move |_window, cx| {
if let Some(app) = weak_app.upgrade() {
app.update(cx, |app, cx| app.prepare_window_close(cx));
window.on_window_should_close(cx, move |window, cx| {
let Some(app) = weak_app.upgrade() else {
return true;
};
// Unsaved editor buffers hold the window open until the question
// about them is answered; the answer closes it.
let asked = app.update(cx, |app, cx| {
let unsaved = app.editor_unsaved();
app.editor_guard_unsaved(
unsaved,
crate::ui::code_editor::AfterUnsaved::CloseWindow,
window,
cx,
)
});
if asked {
return false;
}
app.update(cx, |app, cx| app.prepare_window_close(cx));
true
});
@@ -1742,11 +1757,29 @@ impl Tty7App {
}
}
fn close_window(&self, window: &mut Window, cx: &mut App) {
fn close_window(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let unsaved = self.editor_unsaved();
if self.editor_guard_unsaved(
unsaved,
crate::ui::code_editor::AfterUnsaved::CloseWindow,
window,
cx,
) {
return;
}
self.prepare_window_close(cx);
window.remove_window();
}
/// The window close a question about unsaved files was holding up.
pub(crate) fn close_window_after_unsaved(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.close_window(window, cx);
}
pub(crate) fn teardown_workspace_forwards(&self, cx: &gpui::App) {
let Some(route) = self
.tabs
@@ -2054,6 +2087,34 @@ impl Tty7App {
fn quit_stop_sessions(&mut self, window: &mut Window, cx: &mut Context<Self>) {
cx.activate(true);
window.activate_window();
// Unsaved editor buffers are asked about first, in the window that
// holds them. Quitting ends every window, so another window's
// buffers count as much as this one's: hand the quit over to it, and
// it asks about its own before carrying the quit on.
let unsaved = self.editor_unsaved();
if self.editor_guard_unsaved(
unsaved,
crate::ui::code_editor::AfterUnsaved::Quit,
window,
cx,
) {
return;
}
let me = cx.entity_id();
let elsewhere = crate::ui::windows::WindowRegistry::open_windows(cx)
.into_iter()
.filter_map(|(ws, app)| Some((ws, app.upgrade()?)))
.find(|(_, app)| app.entity_id() != me && !app.read(cx).editor_unsaved().is_empty());
if let Some((ws, app)) = elsewhere
&& let Some(handle) = crate::ui::windows::WindowRegistry::window_for(cx, ws)
{
cx.defer(move |cx| {
let _ = handle.update(cx, |_, window, cx| {
app.update(cx, |app, cx| app.quit_stop_sessions(window, cx));
});
});
return;
}
let answer = window.prompt(
PromptLevel::Warning,
t(crate::ui::i18n::L10nKey::QuitStopServerTitle),
@@ -2075,6 +2136,11 @@ impl Tty7App {
.detach();
}
/// The quit a question about unsaved files was holding up.
pub(crate) fn quit_after_unsaved(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.quit_stop_sessions(window, cx);
}
pub(crate) fn restart_window_daemon(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(remote) = WorkspaceStore::remote_ref(cx, self.workspace) else {
self.restart_daemon(window, cx);
@@ -4152,6 +4218,24 @@ impl Tty7App {
/// so it travels with the close rather than being read back off shared
/// state a second, unrelated close could have overwritten.
fn close_pane_inner(&mut self, confirmed: bool, window: &mut Window, cx: &mut Context<Self>) {
// Closing the last pane closes the tab, and with it the files only
// this tab's editor shows.
if !confirmed
&& self
.tabs
.get(self.active)
.is_some_and(|tab| tab.pane.leaves().len() <= 1)
{
let unsaved = self.editor_unsaved_in_tab(self.active);
if self.editor_guard_unsaved(
unsaved,
crate::ui::code_editor::AfterUnsaved::ClosePane,
window,
cx,
) {
return;
}
}
if !confirmed && let Some(reason) = self.focused_pane_close_reason(window, cx) {
self.ask_before_closing(CloseTarget::Pane, reason, window, cx);
return;
@@ -4195,6 +4279,11 @@ impl Tty7App {
}
}
/// The pane close a question about unsaved files was holding up.
pub(crate) fn close_pane_after_unsaved(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.close_pane_inner(false, window, cx);
}
fn on_child_exited(
&mut self,
view: Entity<TerminalView>,
@@ -4214,7 +4303,11 @@ impl Tty7App {
return;
}
match self.tabs[index].pane.close_leaf(view.entity_id()) {
CloseOutcome::RemoveSelf => self.close_tab(index, window, cx),
// Not the asking close: the pane is already gone, so there is no
// tab left to keep open if the answer were Cancel. Unsaved editor
// files it had are handed to the tab in front instead (see
// `editor_sync`).
CloseOutcome::RemoveSelf => self.close_tab_inner(index, true, window, cx),
CloseOutcome::NotFound => {}
CloseOutcome::Collapsed => {
kill_pane_off_thread(view.read(cx).pane_route(), view.read(cx).pane_id, cx);
@@ -4463,13 +4556,19 @@ impl Tty7App {
}
return;
}
let merged_id = moved.tree_id.get();
let host = &mut self.tabs[self.active];
if host.code.is_none() {
host.code = moved.code;
match (host.code.as_deref_mut(), moved.code) {
(None, code) => host.code = code,
// Both had an editor: the files come along into this tab's strip
// rather than being dropped with the tab that brought them.
(Some(code), Some(theirs)) => code.adopt(&theirs.files),
(Some(_), None) => {}
}
if host.diff_overlay.is_none() {
host.diff_overlay = moved.diff_overlay;
}
self.editor_forget_tab(merged_id, cx);
if self
.renaming
.as_ref()
@@ -5022,12 +5121,27 @@ impl Tty7App {
if index >= self.tabs.len() {
return;
}
if !confirmed {
let id = self.tabs[index].tree_id.get();
let unsaved = self.editor_unsaved_in_tab(index);
if self.editor_guard_unsaved(
unsaved,
crate::ui::code_editor::AfterUnsaved::CloseTab(id),
window,
cx,
) {
return;
}
}
if !confirmed && let Some(reason) = self.tab_close_reason(index, cx) {
let id = self.tabs[index].tree_id.get();
self.ask_before_closing(CloseTarget::Tab(id), reason, window, cx);
return;
}
self.maximized = None;
let closing = self.tabs[index].tree_id.get();
self.editor_close_tab_files(index, cx);
self.editor_forget_tab(closing, cx);
let worktree_cwd = self.tab_host_cwd(index, window, cx);
let snapshot = tab_to_session(&self.tabs[index], cx);
self.closed.push(snapshot);
@@ -5164,7 +5278,12 @@ impl Tty7App {
// that is most of them, and a menu item that quietly closes nothing is
// worse than one that closes what it says.
for i in (0..self.tabs.len()).rev() {
if i == index || self.tab_has_warn_ssh(i, cx) {
// Unsaved editor files are skipped the same way: a bulk close
// cannot ask about them one tab at a time, and must not lose them.
if i == index
|| self.tab_has_warn_ssh(i, cx)
|| !self.editor_unsaved_in_tab(i).is_empty()
{
continue;
}
self.close_tab_inner(i, true, window, cx);
@@ -5179,7 +5298,7 @@ impl Tty7App {
) {
// Same bargain as `close_other_tabs`.
for i in ((index + 1)..self.tabs.len()).rev() {
if self.tab_has_warn_ssh(i, cx) {
if self.tab_has_warn_ssh(i, cx) || !self.editor_unsaved_in_tab(i).is_empty() {
continue;
}
self.close_tab_inner(i, true, window, cx);
@@ -5632,6 +5751,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
}
@@ -5895,6 +6015,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();
}
@@ -6176,6 +6297,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,
@@ -8502,6 +8627,7 @@ impl Render for Tty7App {
self.touch_active_tab();
self.declare_displayed_panes(cx);
self.scm_sync_watchers(window, cx);
self.editor_sync(window, cx);
// Keeps looking for new listening ports on the pane in front, panel
// open or not — a port that appears while the panel is shut is exactly
// the one worth forwarding unasked.
@@ -9050,6 +9176,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)
}))
@@ -9163,12 +9292,32 @@ impl Render for Tty7App {
this.toggle_code_panel(window, cx)
}))
.on_action(cx.listener(|this, _: &EditorSave, window, cx| {
if !this.editor_has_focus(window, cx) {
if !this.editor_panel_has_focus(window, cx) {
cx.propagate();
return;
}
this.editor_save_active(window, cx)
}))
// Same shape as `EditorSave`: the chord belongs to the editor
// only while it has the focus, and reaches the terminal
// otherwise — Ctrl+G is BEL to a shell.
.on_action(cx.listener(|this, _: &EditorSaveAs, window, cx| {
if !this.editor_panel_has_focus(window, cx) {
cx.propagate();
return;
}
this.editor_save_as_active(window, cx)
}))
.on_action(cx.listener(|this, _: &EditorGoToLine, window, cx| {
if !this.editor_panel_has_focus(window, cx) {
cx.propagate();
return;
}
this.editor_go_to_line(window, cx)
}))
.on_action(cx.listener(|this, _: &EditorNewFile, window, cx| {
this.editor_new_file(window, cx)
}))
.on_action(
cx.listener(|this, _: &Quit, window, cx| this.quit_stop_sessions(window, cx)),
)
+2030 -450
View File
File diff suppressed because it is too large Load Diff
+222
View File
@@ -0,0 +1,222 @@
//! Which files each tab had open in the editor, kept across restarts.
//!
//! The tab tree itself lives in the daemon, and the daemon has no business
//! knowing about editor buffers — a file open in the GUI is not something a
//! shell or a remote server can act on. So this is the GUI's own record, keyed
//! by the tree's [`TabId`], which is stable across restarts: the tab that comes
//! back after a relaunch is the same tab, and gets its files back.
//!
//! One store per process, shared by every window, so two windows writing their
//! own tabs never race each other over the file.
use std::collections::HashMap;
use std::path::PathBuf;
use gpui::{App, Global};
use serde::{Deserialize, Serialize};
use tty7_core::core::machine::TabId;
const FILE: &str = "editor-sessions.json";
/// The most tabs remembered. A tab closed in some other client never tells
/// this one, so its record can only age out.
const MAX_TABS: usize = 256;
const WRITE_DELAY: std::time::Duration = std::time::Duration::from_millis(500);
/// One tab's editor, as it was last seen.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct TabEditor {
pub(crate) files: Vec<PathBuf>,
#[serde(default)]
pub(crate) active: usize,
#[serde(default)]
pub(crate) visible: bool,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
struct Entry {
#[serde(flatten)]
state: TabEditor,
/// When this entry last changed, for choosing what to forget first.
#[serde(default)]
touched: u64,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
struct Doc {
#[serde(default)]
tabs: HashMap<TabId, Entry>,
}
#[derive(Default)]
struct EditorSessionStore {
doc: Doc,
write_scheduled: bool,
}
impl Global for EditorSessionStore {}
fn store(cx: &mut App) -> &mut EditorSessionStore {
if cx.try_global::<EditorSessionStore>().is_none() {
cx.set_global(EditorSessionStore {
doc: load(),
write_scheduled: false,
});
}
cx.global_mut::<EditorSessionStore>()
}
fn load() -> Doc {
// A test builds windows like any other run, and must not read — let alone
// rewrite — the editor state of the person running the suite.
if cfg!(test) {
return Doc::default();
}
let Some(path) = crate::core::config::config_path(FILE) else {
return Doc::default();
};
match std::fs::read(&path) {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
log::warn!(
"editor sessions: ignoring unreadable {}: {e}",
path.display()
);
Doc::default()
}),
Err(_) => Doc::default(),
}
}
/// What the tab had open, if anything was recorded for it.
pub(crate) fn get(cx: &mut App, tab: TabId) -> Option<TabEditor> {
store(cx).doc.tabs.get(&tab).map(|e| e.state.clone())
}
/// Records a tab's editor. Writing is deferred and coalesced: this is called
/// whenever the set of open files changes, which a burst of opens makes often.
pub(crate) fn put(cx: &mut App, tab: TabId, state: TabEditor) {
let s = store(cx);
if s.doc.tabs.get(&tab).is_some_and(|e| e.state == state) {
return;
}
s.doc.tabs.insert(
tab,
Entry {
state,
touched: now_secs(),
},
);
prune(&mut s.doc);
schedule_write(cx);
}
/// Forgets a tab that was closed.
pub(crate) fn remove(cx: &mut App, tab: TabId) {
if store(cx).doc.tabs.remove(&tab).is_some() {
schedule_write(cx);
}
}
fn prune(doc: &mut Doc) {
if doc.tabs.len() <= MAX_TABS {
return;
}
let mut by_age: Vec<(u64, TabId)> = doc.tabs.iter().map(|(id, e)| (e.touched, *id)).collect();
by_age.sort_by_key(|(touched, _)| *touched);
for (_, id) in by_age.into_iter().take(doc.tabs.len() - MAX_TABS) {
doc.tabs.remove(&id);
}
}
fn schedule_write(cx: &mut App) {
if cfg!(test) {
return;
}
let s = store(cx);
if s.write_scheduled {
return;
}
s.write_scheduled = true;
cx.spawn(async move |cx| {
cx.background_executor().timer(WRITE_DELAY).await;
let json = cx.update(|cx| {
let s = cx.global_mut::<EditorSessionStore>();
s.write_scheduled = false;
serde_json::to_vec_pretty(&s.doc)
});
let json = match json {
Ok(json) => json,
Err(e) => {
log::warn!("editor sessions: could not serialize: {e}");
return;
}
};
cx.background_executor()
.spawn(async move {
let Some(path) = crate::core::config::config_path(FILE) else {
return;
};
if let Err(e) = crate::core::config::write_atomic(&path, &json) {
log::warn!("editor sessions: could not write {}: {e}", path.display());
}
})
.await;
})
.detach();
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_document_round_trips_with_tab_ids_as_keys() {
let mut doc = Doc::default();
let id = TabId::new();
doc.tabs.insert(
id,
Entry {
state: TabEditor {
files: vec![PathBuf::from("/src/main.rs"), PathBuf::from("/README.md")],
active: 1,
visible: true,
},
touched: 7,
},
);
let json = serde_json::to_string(&doc).unwrap();
let back: Doc = serde_json::from_str(&json).unwrap();
assert_eq!(back.tabs[&id].state, doc.tabs[&id].state);
}
#[test]
fn the_oldest_tabs_are_forgotten_first() {
let mut doc = Doc::default();
let mut ids = Vec::new();
for touched in 0..(MAX_TABS as u64 + 3) {
let id = TabId::new();
ids.push(id);
doc.tabs.insert(
id,
Entry {
state: TabEditor::default(),
touched,
},
);
}
prune(&mut doc);
assert_eq!(doc.tabs.len(), MAX_TABS);
for old in &ids[..3] {
assert!(!doc.tabs.contains_key(old));
}
assert!(doc.tabs.contains_key(ids.last().unwrap()));
}
}
File diff suppressed because it is too large Load Diff
+30 -14
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(),
@@ -1313,14 +1318,25 @@ impl Tty7App {
window,
cx,
move |h| match &op {
TreeWrite::NewFile => h.create_file_new(&target),
TreeWrite::NewFolder => h.create_dir(&target, false),
TreeWrite::Rename { from } => h.rename(from, &target),
TreeWrite::Delete => h.remove(&target, is_dir),
TreeWrite::NewFile => h.create_file_new(&target).map(|()| None),
TreeWrite::NewFolder => h.create_dir(&target, false).map(|()| None),
// Resolved on either side of the move, in the same trip: the
// editor keys its buffers on canonical paths, and once the
// rename lands the old one can no longer be resolved.
TreeWrite::Rename { from } => {
let canon_from = h.canonicalize(from).unwrap_or_else(|_| from.clone());
h.rename(from, &target)?;
let canon_to = h.canonicalize(&target).unwrap_or_else(|_| target.clone());
Ok(Some((canon_from, canon_to)))
}
TreeWrite::Delete => h.remove(&target, is_dir).map(|()| None),
},
move |app, result: std::io::Result<()>, window, cx| {
move |app, result: std::io::Result<Option<(PathBuf, PathBuf)>>, window, cx| {
match result {
Ok(()) => {
Ok(moved) => {
if let Some((from, to)) = moved {
app.editor_path_moved(id, &from, &to, cx);
}
app.file_tree.invalidate_dir(id, &dir);
if matches!(edit, TreeEdit::NewFile { .. }) {
app.open_file_in_editor(&new_path, window, cx);
@@ -1449,10 +1465,14 @@ impl Tty7App {
host,
window,
cx,
move |h| h.remove(&target, is_dir),
move |app, result: std::io::Result<()>, window, cx| {
move |h| {
let canon = h.canonicalize(&target).unwrap_or_else(|_| target.clone());
h.remove(&target, is_dir).map(|()| canon)
},
move |app, result: std::io::Result<PathBuf>, window, cx| {
match result {
Ok(()) => {
Ok(removed) => {
app.editor_path_removed(id, &removed, cx);
app.file_tree.invalidate_dir(id, &parent);
}
Err(e) => {
@@ -1848,11 +1868,7 @@ impl Tty7App {
let sf = cx.global::<crate::ui::presets::Surfaces>().popover;
let tree_host = self.spawn_host(cx);
let dirty = self.tab_code().is_some_and(|c| {
c.files
.iter()
.any(|f| f.dirty && f.host.id() == tree_host && f.path == *path)
});
let dirty = self.editor_is_dirty(tree_host, &path);
let renaming = matches!(
&self.file_tree.editing,
+39 -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 => {
@@ -1168,6 +1179,33 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::EditorWrapOff => "Wrap: off",
L10nKey::EditorFileTooLarge => "\"{path}\" is too large for the editor ({size} MB)",
L10nKey::EditorBinaryFile => "\"{path}\" looks like a binary file",
L10nKey::EditorUntitled => "Untitled-{n}",
L10nKey::EditorUnsavedChangesMany => "{count} files have unsaved changes",
L10nKey::EditorSaveAll => "Save All",
L10nKey::EditorSaveConflictTitle => "\"{name}\" changed on disk",
L10nKey::EditorSaveConflictBody => {
"Another program changed it after it was opened here. Overwriting replaces those changes with yours."
}
L10nKey::EditorOverwrite => "Overwrite",
L10nKey::EditorEncodeFailedTitle => "Can't save \"{name}\" as {encoding}",
L10nKey::EditorEncodeFailedBody => {
"It contains \"{ch}\", which {encoding} can't represent. Save it as UTF-8 instead?"
}
L10nKey::EditorSaveAsUtf8 => "Save as UTF-8",
L10nKey::EditorAlreadyOpen => "\"{path}\" is already open in the editor",
L10nKey::EditorGoToLine => "Go to line",
L10nKey::EditorGoToLineAction => "Go to Line…",
L10nKey::EditorGoToLinePlaceholder => "Line, or line:column (1–{total})",
L10nKey::EditorSaveAs => "Save as",
L10nKey::EditorSaveAsAction => "Save As…",
L10nKey::EditorSaveAsPlaceholder => "Full path to save to",
L10nKey::EditorReplaceExisting => "\"{path}\" already exists. Replace it?",
L10nKey::EditorReplace => "Replace",
L10nKey::EditorNewFile => "New File",
L10nKey::EditorOrphanAdopted => "Unsaved \"{name}\" was moved here from a tab that closed",
L10nKey::EditorFileDeletedOnDisk => "This file was deleted on disk",
L10nKey::EditorIndentSpaces => "Spaces: {n}",
L10nKey::EditorIndentTabs => "Tab Size: {n}",
L10nKey::PanelInfoTitle => "Info",
L10nKey::PanelChangesTitle => "Changes",
L10nKey::PanelScmTitle => "Changes",
+41 -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 => {
@@ -1212,6 +1225,33 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::EditorWrapOff => "折り返し: オフ",
L10nKey::EditorFileTooLarge => "「{path}」はエディタで開くには大きすぎます({size} MB)",
L10nKey::EditorBinaryFile => "「{path}」はバイナリファイルのようです",
L10nKey::EditorUntitled => "無題-{n}",
L10nKey::EditorUnsavedChangesMany => "{count} 個のファイルに未保存の変更があります",
L10nKey::EditorSaveAll => "すべて保存",
L10nKey::EditorSaveConflictTitle => "「{name}」はディスク上で変更されました",
L10nKey::EditorSaveConflictBody => {
"ここで開いた後に別のプログラムが変更しました。上書きすると、その変更はあなたの内容で置き換えられます。"
}
L10nKey::EditorOverwrite => "上書き",
L10nKey::EditorEncodeFailedTitle => "「{name}」を {encoding} で保存できません",
L10nKey::EditorEncodeFailedBody => {
"{encoding} で表せない文字「{ch}」が含まれています。代わりに UTF-8 で保存しますか?"
}
L10nKey::EditorSaveAsUtf8 => "UTF-8 で保存",
L10nKey::EditorAlreadyOpen => "「{path}」はすでにエディタで開かれています",
L10nKey::EditorGoToLine => "行へ移動",
L10nKey::EditorGoToLineAction => "行へ移動…",
L10nKey::EditorGoToLinePlaceholder => "行番号、または 行:列(1–{total})",
L10nKey::EditorSaveAs => "名前を付けて保存",
L10nKey::EditorSaveAsAction => "名前を付けて保存…",
L10nKey::EditorSaveAsPlaceholder => "保存先のフルパス",
L10nKey::EditorReplaceExisting => "「{path}」はすでに存在します。置き換えますか?",
L10nKey::EditorReplace => "置き換え",
L10nKey::EditorNewFile => "新規ファイル",
L10nKey::EditorOrphanAdopted => "閉じたタブから未保存の「{name}」をここに移しました",
L10nKey::EditorFileDeletedOnDisk => "このファイルはディスク上で削除されました",
L10nKey::EditorIndentSpaces => "スペース: {n}",
L10nKey::EditorIndentTabs => "タブ幅: {n}",
L10nKey::PanelInfoTitle => "情報",
L10nKey::PanelChangesTitle => "変更",
L10nKey::PanelScmTitle => "変更",
+32
View File
@@ -178,6 +178,15 @@ l10n_keys! {
SearchPlaceholderActions,
SearchPlaceholderTerminals,
SearchPlaceholderHosts,
SearchTabFiles,
SearchPlaceholderFiles,
SearchFilesNoRoots,
SearchFilesNoRootsHint,
SearchFilesIndexing,
SearchFilesFailed,
SearchFilesGoToLine,
SearchFilesCapped,
CmdGoToFile,
SearchTabSessions,
SearchPlaceholderSessions,
SearchSessionsEmptyHint,
@@ -912,6 +921,29 @@ l10n_keys! {
EditorWrapOff,
EditorFileTooLarge,
EditorBinaryFile,
EditorUntitled,
EditorUnsavedChangesMany,
EditorSaveAll,
EditorSaveConflictTitle,
EditorSaveConflictBody,
EditorOverwrite,
EditorEncodeFailedTitle,
EditorEncodeFailedBody,
EditorSaveAsUtf8,
EditorAlreadyOpen,
EditorGoToLine,
EditorGoToLineAction,
EditorGoToLinePlaceholder,
EditorSaveAs,
EditorSaveAsAction,
EditorSaveAsPlaceholder,
EditorReplaceExisting,
EditorReplace,
EditorNewFile,
EditorOrphanAdopted,
EditorFileDeletedOnDisk,
EditorIndentSpaces,
EditorIndentTabs,
PanelInfoTitle,
PanelChangesTitle,
PanelScmTitle,
+37 -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 的历史会话。",
@@ -1092,6 +1101,33 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::EditorWrapOff => "自动换行:关",
L10nKey::EditorFileTooLarge => "“{path}”太大,无法在编辑器中打开({size} MB)",
L10nKey::EditorBinaryFile => "“{path}”看起来是二进制文件",
L10nKey::EditorUntitled => "未命名-{n}",
L10nKey::EditorUnsavedChangesMany => "{count} 个文件有未保存的更改",
L10nKey::EditorSaveAll => "全部保存",
L10nKey::EditorSaveConflictTitle => "“{name}”在磁盘上已被修改",
L10nKey::EditorSaveConflictBody => {
"打开之后,另一个程序修改了它。覆盖会用你的内容替换那些修改。"
}
L10nKey::EditorOverwrite => "覆盖",
L10nKey::EditorEncodeFailedTitle => "无法以 {encoding} 保存“{name}”",
L10nKey::EditorEncodeFailedBody => {
"其中包含 {encoding} 无法表示的字符“{ch}”。改为以 UTF-8 保存吗?"
}
L10nKey::EditorSaveAsUtf8 => "以 UTF-8 保存",
L10nKey::EditorAlreadyOpen => "“{path}”已在编辑器中打开",
L10nKey::EditorGoToLine => "跳转到行",
L10nKey::EditorGoToLineAction => "跳转到行…",
L10nKey::EditorGoToLinePlaceholder => "行号,或 行:列(1–{total})",
L10nKey::EditorSaveAs => "另存为",
L10nKey::EditorSaveAsAction => "另存为…",
L10nKey::EditorSaveAsPlaceholder => "要保存到的完整路径",
L10nKey::EditorReplaceExisting => "“{path}”已存在。要替换吗?",
L10nKey::EditorReplace => "替换",
L10nKey::EditorNewFile => "新建文件",
L10nKey::EditorOrphanAdopted => "未保存的“{name}”已从关闭的标签页移到这里",
L10nKey::EditorFileDeletedOnDisk => "此文件已在磁盘上被删除",
L10nKey::EditorIndentSpaces => "空格:{n}",
L10nKey::EditorIndentTabs => "Tab 宽度:{n}",
L10nKey::PanelInfoTitle => "信息",
L10nKey::PanelChangesTitle => "更改",
L10nKey::PanelScmTitle => "更改",
+34 -2
View File
@@ -279,7 +279,8 @@ fn steals_a_control_code(chord: &str) -> bool {
/// The bindings allowed to sit on a control code anyway.
///
/// `EditorSave` stays on Ctrl+S because its handler in `app.rs` calls
/// `EditorSave` stays on Ctrl+S — and `EditorGoToLine` on Ctrl+G, as in
/// every other code editor — because its handler in `app.rs` calls
/// `cx.propagate()` whenever the editor does not have focus, so the keystroke
/// reaches the terminal as XOFF instead of dying at the window. Ctrl+V is the
/// paste chord every Windows and Linux desktop trains its users on; tty7
@@ -290,7 +291,8 @@ fn steals_a_control_code(chord: &str) -> bool {
/// Anything else added here needs a fall-through of its own; a binding that
/// simply swallows the byte does not belong on this list.
fn control_code_binding_allowed(action: &str, chord: &str) -> bool {
action == "EditorSave" || (cfg!(not(target_os = "macos")) && chord == "ctrl-v")
matches!(action, "EditorSave" | "EditorGoToLine")
|| (cfg!(not(target_os = "macos")) && chord == "ctrl-v")
}
fn per_platform(mac: &'static str, other: &'static str) -> &'static str {
@@ -441,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"),
@@ -578,6 +588,10 @@ fn shipped_bindings() -> Vec<(&'static str, &'static str)> {
("ShowRightPanelChanges", ""),
("ShowRightPanelGitHub", ""),
("EditorSave", "secondary-s"),
("EditorSaveAs", "secondary-shift-s"),
("EditorGoToLine", "ctrl-g"),
// Unbound: ⌘N is New Window, and the editor's header has a + for it.
("EditorNewFile", ""),
("OpenSshProfiles", ""),
("RestartSshSession", "secondary-shift-r"),
("Quit", per_platform("secondary-q", "secondary-shift-q")),
@@ -856,6 +870,18 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> {
t(L10nKey::KeybindInsertNewline).to_string(),
),
"EditorSave" => (CommandGroup::Terminal, t(L10nKey::Save).to_string()),
"EditorSaveAs" => (
CommandGroup::Terminal,
t(L10nKey::EditorSaveAsAction).to_string(),
),
"EditorGoToLine" => (
CommandGroup::Terminal,
t(L10nKey::EditorGoToLineAction).to_string(),
),
"EditorNewFile" => (
CommandGroup::Terminal,
t(L10nKey::EditorNewFile).to_string(),
),
"OpenSshProfiles" => (
CommandGroup::Ssh,
t(L10nKey::CmdSshManageProfiles).to_string(),
@@ -892,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(),
@@ -1474,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),
@@ -1529,6 +1557,9 @@ fn make_binding(action: &str, keystroke: &str) -> Option<KeyBinding> {
"ToggleDocumentPreview" => KeyBinding::new(keystroke, ToggleDocumentPreview, None),
"ToggleDocumentWrap" => KeyBinding::new(keystroke, ToggleDocumentWrap, None),
"EditorSave" => KeyBinding::new(keystroke, EditorSave, None),
"EditorSaveAs" => KeyBinding::new(keystroke, EditorSaveAs, None),
"EditorGoToLine" => KeyBinding::new(keystroke, EditorGoToLine, None),
"EditorNewFile" => KeyBinding::new(keystroke, EditorNewFile, None),
"OpenSshProfiles" => KeyBinding::new(keystroke, OpenSshProfiles, None),
"RestartSshSession" => KeyBinding::new(keystroke, RestartSshSession, None),
"Quit" => KeyBinding::new(keystroke, Quit, None),
@@ -2175,6 +2206,7 @@ mod tests {
);
assert!(!control_code_binding_allowed("PasteText", "ctrl-d"));
assert!(control_code_binding_allowed("EditorSave", "secondary-s"));
assert!(control_code_binding_allowed("EditorGoToLine", "ctrl-g"));
}
#[test]
+2
View File
@@ -7,6 +7,8 @@ pub mod diff_list;
pub mod diff_overlay;
pub mod diff_rows;
pub mod document_column;
pub mod editor_session;
pub mod editor_text;
pub mod file_copy;
pub mod file_tree;
pub mod forwards;
+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`]
//! — 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),