mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-10-03 00:01:55 +00:00
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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
@@ -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 => "変更",
|
||||
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`]
|
||||
//! — 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