From bf5149bea00480bf052640b44ac80e0ea744f691 Mon Sep 17 00:00:00 2001
From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Date: Mon, 28 Sep 2026 00:32:18 +0800
Subject: [PATCH] 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.
---
.github/scripts/check-host-boundary.sh | 4 +
Cargo.lock | 2 +
Cargo.toml | 8 +
crates/tty7-core/src/host/local.rs | 310 ++-
src/core/actions.rs | 4 +
src/terminal/git_data.rs | 5 +-
src/ui/app.rs | 169 +-
src/ui/code_editor.rs | 2480 +++++++++++++++++++-----
src/ui/editor_session.rs | 222 +++
src/ui/editor_text.rs | 1319 +++++++++++++
src/ui/file_tree.rs | 44 +-
src/ui/i18n/en.rs | 40 +-
src/ui/i18n/ja.rs | 42 +-
src/ui/i18n/mod.rs | 32 +
src/ui/i18n/zh.rs | 38 +-
src/ui/keymap.rs | 36 +-
src/ui/mod.rs | 2 +
src/ui/search/command.rs | 14 +
src/ui/search/files.rs | 743 +++++++
src/ui/search/mod.rs | 21 +-
src/ui/search/sources.rs | 78 +-
src/ui/search/view.rs | 158 +-
src/ui/theme.rs | 1 +
23 files changed, 5268 insertions(+), 504 deletions(-)
create mode 100644 src/ui/editor_session.rs
create mode 100644 src/ui/editor_text.rs
create mode 100644 src/ui/search/files.rs
diff --git a/.github/scripts/check-host-boundary.sh b/.github/scripts/check-host-boundary.sh
index e2cf94cf..f7adaf4f 100755
--- a/.github/scripts/check-host-boundary.sh
+++ b/.github/scripts/check-host-boundary.sh
@@ -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.
diff --git a/Cargo.lock b/Cargo.lock
index a9c62a65..2517a518 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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",
diff --git a/Cargo.toml b/Cargo.toml
index 764a826b..ea5e7962 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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`
diff --git a/crates/tty7-core/src/host/local.rs b/crates/tty7-core/src/host/local.rs
index e9c25631..7a21324e 100644
--- a/crates/tty7-core/src/host/local.rs
+++ b/crates/tty7-core/src/host/local.rs
@@ -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 {
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,
@@ -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 {
+ let mut v: Vec = 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"]);
+ }
}
diff --git a/src/core/actions.rs b/src/core/actions.rs
index 6dfd964c..1fb5be95 100644
--- a/src/core/actions.rs
+++ b/src/core/actions.rs
@@ -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,
diff --git a/src/terminal/git_data.rs b/src/terminal/git_data.rs
index f69da8ab..75b27a7e 100644
--- a/src/terminal/git_data.rs
+++ b/src/terminal/git_data.rs
@@ -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::()?
- .repo_root_for(host, open.path.parent()?)?;
+ .repo_root_for(host, path.parent()?)?;
Some((host, root.to_path_buf()))
}
diff --git a/src/ui/app.rs b/src/ui/app.rs
index 358a414e..619a3427 100644
--- a/src/ui/app.rs
+++ b/src/ui/app.rs
@@ -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) {
+ 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.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) {
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.quit_stop_sessions(window, cx);
+ }
+
pub(crate) fn restart_window_daemon(&mut self, window: &mut Window, cx: &mut Context) {
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) {
+ // 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.close_pane_inner(false, window, cx);
+ }
+
fn on_child_exited(
&mut self,
view: Entity,
@@ -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)),
)
diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs
index 4cc4586c..4068ee28 100644
--- a/src/ui/code_editor.rs
+++ b/src/ui/code_editor.rs
@@ -1,21 +1,24 @@
-use std::collections::HashSet;
+use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use gpui::prelude::*;
use gpui::{
- AnyElement, Context, Entity, Focusable as _, PromptLevel, SharedString, Subscription, Window,
- div, px,
+ AnyElement, Context, Entity, EntityInputHandler as _, Focusable as _, MouseButton, PromptLevel,
+ SharedString, Subscription, Window, div, px,
};
use gpui_component::button::{Button, ButtonVariants as _};
-use gpui_component::input::{Input, InputEvent, InputState, Position, TabSize};
+use gpui_component::input::{Input, InputEvent, InputState, Position, RopeExt as _, TabSize};
use gpui_component::menu::ContextMenuExt as _;
use gpui_component::{
ActiveTheme as _, Icon, IconName, Sizable as _, WindowExt as _, h_flex, v_flex,
};
+use tty7_core::core::machine::TabId;
use crate::ui::app::Tty7App;
use crate::ui::document_column::DocumentChrome;
+use crate::ui::editor_session::{self, TabEditor};
+use crate::ui::editor_text::{self, EditorConfig, Indent, LineEnding, TextFormat};
use crate::ui::host_ops::{HostId, HostOps, MTime, SharedHost, WatchSub};
use crate::ui::i18n::{L10nKey, t, t_fmt};
@@ -23,8 +26,69 @@ const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024;
const RELOAD_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(200);
+/// How far up the tree to look for `.editorconfig` files. Each level is one
+/// round trip on a remote host, and a project nested deeper than this without
+/// a `root = true` somewhere above it is not one worth stalling an open for.
+const EDITORCONFIG_DEPTH: usize = 16;
+
+/// An open buffer is named by its input's entity id: unique, stable for the
+/// buffer's life, and already what every async landing looks it up by.
+pub(crate) type BufferId = gpui::EntityId;
+
+/// What a buffer's text looked like the last time it matched the disk.
+///
+/// Dirtiness is a comparison against this rather than a flag set by the first
+/// keystroke, so typing a character and deleting it again — or undoing back to
+/// the saved text — leaves the file clean, the way every other editor does.
+/// The length is checked first: it is free, and it settles almost every
+/// keystroke without hashing the whole file.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+struct Fingerprint {
+ len: usize,
+ hash: u64,
+}
+
+impl Fingerprint {
+ fn of_str(text: &str) -> Self {
+ Self::of_chunks(text.len(), std::iter::once(text))
+ }
+
+ fn of_chunks<'a>(len: usize, chunks: impl Iterator- ) -> Self {
+ use std::hash::Hasher as _;
+ // SipHash buffers its input, so the same bytes hash the same however
+ // the rope happens to have split them into chunks.
+ let mut hasher = std::hash::DefaultHasher::new();
+ for chunk in chunks {
+ hasher.write(chunk.as_bytes());
+ }
+ Self {
+ len,
+ hash: hasher.finish(),
+ }
+ }
+}
+
+/// Why the buffer and the file on disk no longer agree.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum DiskConflict {
+ /// Something else wrote the file while the buffer had edits of its own.
+ /// Carries the modification time that was seen, so "Keep mine" can accept
+ /// exactly that version as the one a save may replace.
+ Changed(Option
),
+ /// The file is gone. The buffer is all that is left of it.
+ Deleted,
+}
+
+/// One open file, shared by every tab that shows it.
+///
+/// Buffers used to belong to a tab, which meant the same file open in two tabs
+/// was two independent copies: edits in one were invisible to the other, and
+/// saving either silently threw the other's away. Now a tab only lists which
+/// buffers it shows, and the text lives here once.
pub(crate) struct OpenFile {
pub(crate) path: PathBuf,
+ /// Set for a file that has never been saved; `path` is empty until then.
+ untitled: Option,
/// The machine `path` lives on, held rather than looked up. Saves,
/// reloads and duplicate detection all key on its id — an SFTP file and a
/// local file can share the string `/etc/hosts` without being the same
@@ -33,13 +97,19 @@ pub(crate) struct OpenFile {
pub(crate) host: SharedHost,
pub(crate) input: Entity,
pub(crate) dirty: bool,
+ saved: Fingerprint,
+ /// How the bytes on disk are encoded, so a save writes them back the same
+ /// way — a CRLF file stays CRLF, a GB18030 file stays GB18030.
+ format: TextFormat,
+ saved_format: TextFormat,
+ config: EditorConfig,
+ indent: Indent,
disk_mtime: Option,
- edit_seq: u64,
- saving: Option,
+ saving: bool,
save_pending: bool,
save_then_close: bool,
reload_seq: u64,
- pub(crate) conflict: bool,
+ pub(crate) conflict: Option,
pub(crate) preview: bool,
pub(crate) wrap: bool,
/// The rendered-Markdown pane's own scroll. Per file, so switching away
@@ -52,18 +122,38 @@ pub(crate) struct OpenFile {
}
impl OpenFile {
+ fn id(&self) -> BufferId {
+ self.input.entity_id()
+ }
+
fn label(&self) -> SharedString {
+ if let Some(n) = self.untitled {
+ return t_fmt(L10nKey::EditorUntitled, &[("n", &n.to_string())]).into();
+ }
self.path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| self.path.display().to_string())
.into()
}
+
+ fn is_at(&self, host: HostId, path: &Path) -> bool {
+ self.untitled.is_none() && self.host.id() == host && self.path == path
+ }
+
+ fn language(&self) -> &'static str {
+ if self.untitled.is_some() {
+ return "text";
+ }
+ language_for_path(&self.path)
+ }
}
+/// One tab's view of the editor: which buffers it shows, in the order its
+/// strip draws them, and which one is in front.
pub(crate) struct TabCode {
pub(crate) visible: bool,
- pub(crate) files: Vec,
+ pub(crate) files: Vec,
pub(crate) active: usize,
pub(crate) roots: Vec,
pub(crate) expanded: std::collections::HashSet,
@@ -82,21 +172,104 @@ impl TabCode {
}
}
- pub(crate) fn active_file(&self) -> Option<&OpenFile> {
- self.files.get(self.active)
+ pub(crate) fn active_id(&self) -> Option {
+ self.files.get(self.active).copied()
}
- fn active_file_mut(&mut self) -> Option<&mut OpenFile> {
- self.files.get_mut(self.active)
+ /// Adds a buffer just right of the one in front, the way a browser opens
+ /// a tab, and brings it forward. One the tab already shows only comes
+ /// forward — it keeps its place in the strip.
+ fn show(&mut self, id: BufferId) {
+ if let Some(pos) = self.files.iter().position(|f| *f == id) {
+ self.active = pos;
+ return;
+ }
+ let at = if self.files.is_empty() {
+ 0
+ } else {
+ (self.active + 1).min(self.files.len())
+ };
+ self.files.insert(at, id);
+ self.active = at;
+ }
+
+ /// Lists a buffer at the end of the strip without bringing it forward —
+ /// for files arriving in the background: a restore, a merge, a rescue.
+ fn append(&mut self, id: BufferId) {
+ if !self.files.contains(&id) {
+ self.files.push(id);
+ }
+ }
+
+ /// Lists another strip's files after this one's, in their order.
+ pub(crate) fn adopt(&mut self, ids: &[BufferId]) {
+ for id in ids {
+ self.append(*id);
+ }
+ }
+
+ /// Takes a buffer out of the strip. The neighbour that slides into its
+ /// place comes forward, so closing tabs one after another walks along the
+ /// strip rather than jumping about.
+ fn forget(&mut self, id: BufferId) -> bool {
+ let Some(pos) = self.files.iter().position(|f| *f == id) else {
+ return false;
+ };
+ self.files.remove(pos);
+ if self.active > pos || self.active >= self.files.len() {
+ self.active = self.active.saturating_sub(1);
+ }
+ true
}
}
+/// What a question about unsaved files was standing in the way of.
+#[derive(Clone, Copy, Debug)]
+pub(crate) enum AfterUnsaved {
+ CloseTab(TabId),
+ ClosePane,
+ CloseWindow,
+ Quit,
+}
+
+/// Saves that something is waiting on: once every buffer in `ids` has landed
+/// clean, `then` runs. Any one of them failing drops the wait, and with it
+/// the close it would have carried out.
+struct SaveWaiter {
+ ids: Vec,
+ then: AfterUnsaved,
+}
+
+enum BarKind {
+ GoToLine,
+ SaveAs { id: BufferId, then_close: bool },
+}
+
+/// The one-line prompt that sits above the text: go to line, or name a file
+/// to save to on a machine the native save panel cannot browse.
+struct EditorBar {
+ kind: BarKind,
+ input: Entity,
+ _sub: Subscription,
+}
+
pub(crate) struct EditorPanelState {
+ /// Every open buffer in this window. Tabs refer to these by id.
+ buffers: Vec,
+ next_untitled: u32,
/// Where to put the cursor once a particular file is on screen, for a
/// `file.rs:120:3` that has to load first. Carried rather than applied at
/// the call site because opening is asynchronous: the click is long over
/// by the time there is a buffer to put a cursor in.
pending_cursor: Option<(PathBuf, u32, u32)>,
+ bar: Option,
+ waiters: Vec,
+ unsaved_prompt_open: bool,
+ /// Tabs whose remembered files have been reopened (or found to have none).
+ restored: HashSet,
+ /// What was last written to the session store for each tab, so an
+ /// unchanged tab costs a comparison per frame and nothing more.
+ recorded: HashMap,
watch: Option>,
watch_host: Option,
watch_opening: bool,
@@ -131,7 +304,14 @@ impl EditorPanelState {
})
.detach();
Self {
+ buffers: Vec::new(),
+ next_untitled: 1,
pending_cursor: None,
+ bar: None,
+ waiters: Vec::new(),
+ unsaved_prompt_open: false,
+ restored: HashSet::new(),
+ recorded: HashMap::new(),
watch: None,
watch_host: None,
watch_opening: false,
@@ -193,7 +373,7 @@ pub(crate) fn language_for_path(path: &Path) -> &'static str {
"astro" => "astro",
"graphql" | "gql" => "graphql",
"cs" => "csharp",
- "cmake" => "cmake",
+ "cmake" | "mk" => "cmake",
_ => "text",
}
}
@@ -236,6 +416,74 @@ fn place_cursor(
window.refresh();
}
+/// Replaces a buffer's text with `new` as one ordinary edit.
+///
+/// `InputState::set_value` would be simpler, and it clears the undo history —
+/// so a file reloaded because an agent or a `git checkout` touched it could no
+/// longer be undone past that moment. Replacing only the span that differs is
+/// an edit like any other: it goes on the undo stack, and the cursor and
+/// scroll stay where they were for everything outside it.
+fn replace_buffer_text(
+ input: &Entity,
+ new: &str,
+ window: &mut Window,
+ cx: &mut gpui::App,
+) {
+ let old = input.read(cx).text().to_string();
+ let Some((start, old_end, new_end)) = differing_span(&old, new) else {
+ return;
+ };
+ let start16 = old[..start].encode_utf16().count();
+ let end16 = start16 + old[start..old_end].encode_utf16().count();
+ let replacement = &new[start..new_end];
+ input.update(cx, |state, cx| {
+ let cursor = state.cursor_position();
+ state.replace_text_in_range(Some(start16..end16), replacement, window, cx);
+ state.set_cursor_position(cursor, window, cx);
+ });
+}
+
+/// The byte span where `old` and `new` differ: `(start, end in old, end in
+/// new)`, on character boundaries in both. `None` when they are equal.
+fn differing_span(old: &str, new: &str) -> Option<(usize, usize, usize)> {
+ if old == new {
+ return None;
+ }
+ let mut start = old
+ .bytes()
+ .zip(new.bytes())
+ .take_while(|(a, b)| a == b)
+ .count();
+ while !old.is_char_boundary(start) || !new.is_char_boundary(start) {
+ start -= 1;
+ }
+ let max_suffix = old.len().min(new.len()) - start;
+ let mut suffix = old
+ .bytes()
+ .rev()
+ .zip(new.bytes().rev())
+ .take(max_suffix)
+ .take_while(|(a, b)| a == b)
+ .count();
+ while !old.is_char_boundary(old.len() - suffix) || !new.is_char_boundary(new.len() - suffix) {
+ suffix -= 1;
+ }
+ Some((start, old.len() - suffix, new.len() - suffix))
+}
+
+/// Parses what the go-to-line bar was given: `120`, `120:4` or `:120`.
+/// One-based, as every compiler and the status bar count.
+fn parse_line_target(text: &str) -> Option<(u32, u32)> {
+ let text = text.trim().trim_start_matches(':');
+ let mut parts = text.splitn(2, [':', ',']);
+ let line: u32 = parts.next()?.trim().parse().ok()?;
+ let column = match parts.next() {
+ Some(c) if !c.trim().is_empty() => c.trim().parse().ok()?,
+ _ => 1,
+ };
+ (line > 0).then_some((line, column.max(1)))
+}
+
/// Why the built-in editor could not take a file.
enum EditorOpenError {
/// Not text, so the editor was never the right place for it.
@@ -244,8 +492,76 @@ enum EditorOpenError {
Message(String),
}
-fn looks_binary(bytes: &[u8]) -> bool {
- bytes.iter().take(8192).any(|b| *b == 0)
+/// A file read off its host, decoded, with everything needed to edit it.
+struct Loaded {
+ path: PathBuf,
+ text: String,
+ format: TextFormat,
+ mtime: Option,
+ config: EditorConfig,
+}
+
+/// Reads, sizes, decodes and configures a file, on the host's own thread.
+fn load_file(h: &dyn tty7_core::host::Host, requested: PathBuf) -> Result {
+ let path = h.canonicalize(&requested).unwrap_or(requested);
+ let meta = h.stat(&path).map_err(|e| {
+ EditorOpenError::Message(t_fmt(
+ L10nKey::EditorCantOpen,
+ &[("path", &path.display().to_string()), ("e", &e.to_string())],
+ ))
+ })?;
+ if meta.len > MAX_FILE_BYTES {
+ return Err(EditorOpenError::Message(t_fmt(
+ L10nKey::EditorFileTooLarge,
+ &[
+ ("path", &path.display().to_string()),
+ ("size", &(meta.len / (1024 * 1024)).to_string()),
+ ],
+ )));
+ }
+ let bytes = h.read_file(&path, MAX_FILE_BYTES).map_err(|e| {
+ EditorOpenError::Message(t_fmt(
+ L10nKey::EditorCantRead,
+ &[("path", &path.display().to_string()), ("e", &e.to_string())],
+ ))
+ })?;
+ let Some(decoded) = editor_text::decode(&bytes) else {
+ return Err(EditorOpenError::NotText(path));
+ };
+ let config = read_editorconfig(h, &path);
+ Ok(Loaded {
+ path,
+ text: decoded.text,
+ format: decoded.format,
+ mtime: meta.mtime,
+ config,
+ })
+}
+
+/// Collects the `.editorconfig` files that apply to `path`, nearest first,
+/// stopping at the first one that declares itself the root.
+fn read_editorconfig(h: &dyn tty7_core::host::Host, path: &Path) -> EditorConfig {
+ let mut configs = Vec::new();
+ for dir in path.ancestors().skip(1).take(EDITORCONFIG_DEPTH) {
+ let file = h.join(dir, editor_text::EDITORCONFIG);
+ let Ok(bytes) = h.read_file(&file, 64 * 1024) else {
+ continue;
+ };
+ let contents = String::from_utf8_lossy(&bytes).into_owned();
+ let root = editor_text::is_root(&contents);
+ configs.push((dir.to_path_buf(), contents));
+ if root {
+ break;
+ }
+ }
+ editor_text::editorconfig_for(path, &configs)
+}
+
+fn tab_size(indent: Indent) -> TabSize {
+ TabSize {
+ tab_size: indent.size.max(1),
+ hard_tabs: indent.hard_tabs,
+ }
}
/// Whether handing this path to the desktop would run it rather than show it.
@@ -289,10 +605,19 @@ fn is_program(path: &Path) -> bool {
}
}
+/// What a watcher saw at a path: the file's modification time, or that it
+/// is not there any more.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum Observed {
+ Present(Option),
+ Missing,
+}
+
#[derive(Debug, PartialEq, Eq)]
enum ExternalChange {
Ignore,
Conflict,
+ Deleted,
Reload,
}
@@ -300,11 +625,15 @@ fn classify_external_change(
saving: bool,
dirty: bool,
disk_mtime: Option,
- observed: Option,
+ observed: Observed,
) -> ExternalChange {
if saving {
return ExternalChange::Ignore;
}
+ let observed = match observed {
+ Observed::Missing => return ExternalChange::Deleted,
+ Observed::Present(m) => m,
+ };
if observed.is_some() && observed == disk_mtime {
return ExternalChange::Ignore;
}
@@ -315,16 +644,40 @@ fn classify_external_change(
}
}
-#[derive(Debug, PartialEq, Eq)]
-struct SaveLanding {
- clean: bool,
- requeue: bool,
+/// How a save attempt ended, as reported back from the host's thread.
+enum SaveOutcome {
+ Saved(Option),
+ /// The file changed on disk since the buffer last matched it; nothing
+ /// was written.
+ Conflict(Option),
+ Failed(std::io::Error),
}
-fn settle_save(ok: bool, wrote_seq: u64, current_seq: u64, pending: bool) -> SaveLanding {
- SaveLanding {
- clean: ok && wrote_seq == current_seq,
- requeue: ok && pending,
+/// Writes `bytes` to `target`, first making sure nothing else wrote it since
+/// the buffer last matched it. `expect` is `None` to write regardless — a
+/// Save As, an Overwrite the user chose, or a file being recreated.
+///
+/// This is also the only external-change detection a host without a watcher
+/// gets: an SFTP buffer never hears about someone else's edit until now.
+fn write_checked(
+ h: &dyn tty7_core::host::Host,
+ target: &Path,
+ bytes: &[u8],
+ expect: Option>,
+) -> SaveOutcome {
+ if let Some(expected) = expect {
+ match h.stat(target) {
+ Ok(meta) if meta.mtime != expected => return SaveOutcome::Conflict(meta.mtime),
+ Ok(_) => {}
+ // Gone since it was opened: saving puts it back, which is what a
+ // save of a deleted file means.
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
+ Err(e) => return SaveOutcome::Failed(e),
+ }
+ }
+ match h.write_file(target, bytes) {
+ Ok(meta) => SaveOutcome::Saved(meta.mtime),
+ Err(e) => SaveOutcome::Failed(e),
}
}
@@ -346,24 +699,92 @@ impl Tty7App {
self.tab_code().is_some_and(|c| c.visible)
}
+ fn buffer(&self, id: BufferId) -> Option<&OpenFile> {
+ self.editor.buffers.iter().find(|b| b.id() == id)
+ }
+
+ fn buffer_mut(&mut self, id: BufferId) -> Option<&mut OpenFile> {
+ self.editor.buffers.iter_mut().find(|b| b.id() == id)
+ }
+
+ fn buffer_at(&self, host: HostId, path: &Path) -> Option {
+ self.editor
+ .buffers
+ .iter()
+ .find(|b| b.is_at(host, path))
+ .map(OpenFile::id)
+ }
+
+ /// The buffer in front of the active tab's editor.
+ fn active_buffer(&self) -> Option<&OpenFile> {
+ self.buffer(self.tab_code()?.active_id()?)
+ }
+
+ /// Where the file in front of the active tab's editor lives, if it has
+ /// been saved anywhere yet.
+ pub(crate) fn editor_active_location(&self) -> Option<(HostId, &Path)> {
+ let f = self.active_buffer()?;
+ f.untitled
+ .is_none()
+ .then(|| (f.host.id(), f.path.as_path()))
+ }
+
+ fn tab_index_of(&self, tab: TabId) -> Option {
+ self.tabs.iter().position(|t| t.tree_id.get() == tab)
+ }
+
+ /// How many tabs show this buffer.
+ fn buffer_refs(&self, id: BufferId) -> usize {
+ self.tabs
+ .iter()
+ .filter_map(|t| t.code.as_deref())
+ .filter(|c| c.files.contains(&id))
+ .count()
+ }
+
+ /// Whether the file tree should mark this path as having unsaved edits.
+ pub(crate) fn editor_is_dirty(&self, host: HostId, path: &Path) -> bool {
+ self.editor
+ .buffers
+ .iter()
+ .any(|b| b.dirty && b.is_at(host, path))
+ }
+
+ /// Unsaved buffers that only tab `tab_ix` shows — the ones closing it
+ /// would lose. A buffer another tab also shows survives the close.
+ pub(crate) fn editor_unsaved_in_tab(&self, tab_ix: usize) -> Vec {
+ let Some(code) = self.tabs.get(tab_ix).and_then(|t| t.code.as_deref()) else {
+ return Vec::new();
+ };
+ code.files
+ .iter()
+ .copied()
+ .filter(|id| self.buffer(*id).is_some_and(|b| b.dirty) && self.buffer_refs(*id) == 1)
+ .collect()
+ }
+
+ /// Every unsaved buffer in the window.
+ pub(crate) fn editor_unsaved(&self) -> Vec {
+ self.editor
+ .buffers
+ .iter()
+ .filter(|b| b.dirty)
+ .map(OpenFile::id)
+ .collect()
+ }
+
fn editor_rebuild_watcher(&mut self, cx: &mut Context) {
// Only files on the host the watch itself runs on. A path from
// another machine — an SFTP file, say — does not exist under that
// watcher's feet, and would either miss or, worse, match a local file
- // that happens to share its name.
- //
- // A host that cannot watch therefore gets no external-change
- // detection at all: an SFTP buffer will not notice the file changing
- // underneath it, and saving overwrites whatever is there. Catching
- // that at save time needs a "keep mine" that survives to the next
- // save, which the conflict banner does not have yet.
+ // that happens to share its name. Those are checked when they are
+ // saved instead: see `write_checked`.
let watch_host = self.spawn_host(cx);
let files: HashSet = self
- .tabs
+ .editor
+ .buffers
.iter()
- .filter_map(|t| t.code.as_deref())
- .flat_map(|c| c.files.iter())
- .filter(|f| f.host.id() == watch_host)
+ .filter(|f| f.untitled.is_none() && f.host.id() == watch_host)
.map(|f| f.path.clone())
.collect();
let dirs: HashSet = files
@@ -505,9 +926,8 @@ impl Tty7App {
/// than throwing the cursor somewhere it was never meant to go.
fn apply_pending_cursor(
&mut self,
- host: HostId,
+ id: BufferId,
requested: &Path,
- opened: &Path,
window: &mut Window,
cx: &mut Context,
) {
@@ -524,11 +944,7 @@ impl Tty7App {
let Some((_, line, column)) = self.editor.pending_cursor.take() else {
return;
};
- let Some(file) = self.tab_code_mut().and_then(|c| {
- c.files
- .iter_mut()
- .find(|f| f.host.id() == host && f.path == *opened)
- }) else {
+ let Some(file) = self.buffer_mut(id) else {
return;
};
// A line to land on is a place in the source. A Markdown file that
@@ -566,63 +982,31 @@ impl Tty7App {
window: &mut Window,
cx: &mut Context,
) {
- if self.tabs.get(self.active).is_none() {
+ let Some(tab) = self.tabs.get(self.active).map(|t| t.tree_id.get()) else {
return;
- }
+ };
self.raise_code_overlay();
- if self.editor_activate_open(host.id(), path, window, cx) {
+ if let Some(id) = self.buffer_at(host.id(), path) {
+ self.editor_show_in_tab(tab, id, true, window, cx);
+ self.apply_pending_cursor(id, path, window, cx);
return;
}
- let host_id = host.id();
let p = path.to_path_buf();
let requested = p.clone();
+ let host_id = host.id();
HostOps::run_in(
host.clone(),
window,
cx,
- move |h| -> Result<(PathBuf, String, Option), EditorOpenError> {
- let path = h.canonicalize(&p).unwrap_or(p);
- let meta = match h.stat(&path) {
- Ok(m) => m,
- Err(e) => {
- return Err(EditorOpenError::Message(t_fmt(
- L10nKey::EditorCantOpen,
- &[("path", &path.display().to_string()), ("e", &e.to_string())],
- )));
- }
- };
- if meta.len > MAX_FILE_BYTES {
- return Err(EditorOpenError::Message(t_fmt(
- L10nKey::EditorFileTooLarge,
- &[
- ("path", &path.display().to_string()),
- ("size", &(meta.len / (1024 * 1024)).to_string()),
- ],
- )));
- }
- let bytes = match h.read_file(&path, MAX_FILE_BYTES) {
- Ok(b) => b,
- Err(e) => {
- return Err(EditorOpenError::Message(t_fmt(
- L10nKey::EditorCantRead,
- &[("path", &path.display().to_string()), ("e", &e.to_string())],
- )));
- }
- };
- if looks_binary(&bytes) {
- return Err(EditorOpenError::NotText(path));
- }
- let text =
- String::from_utf8(bytes).map_err(|_| EditorOpenError::NotText(path.clone()))?;
- Ok((path, text, meta.mtime))
- },
+ move |h| load_file(h, p),
move |app, opened, window, cx| match opened {
- Ok((path, text, mtime)) => {
- app.editor_install_file(host, path.clone(), text, mtime, window, cx);
- // Against `requested`, not `path`: the host canonicalised
- // it on the way through, and a link that named a symlink
- // would otherwise lose the line it asked for.
- app.apply_pending_cursor(host_id, &requested, &path, window, cx);
+ Ok(loaded) => {
+ let id = app.editor_install(host, loaded, tab, true, window, cx);
+ // Against `requested`, not the loaded path: the host
+ // canonicalised it on the way through, and a link that
+ // named a symlink would otherwise lose the line it asked
+ // for.
+ app.apply_pending_cursor(id, &requested, window, cx);
}
Err(EditorOpenError::NotText(path)) => {
app.open_outside_the_editor(host_id, &path, window, cx);
@@ -676,50 +1060,93 @@ impl Tty7App {
}
}
- fn editor_activate_open(
+ /// Puts a buffer in a tab's strip and, when `front`, in front of its
+ /// editor with the panel open and focused.
+ fn editor_show_in_tab(
&mut self,
- host: HostId,
- path: &Path,
- window: &mut Window,
- cx: &mut Context,
- ) -> bool {
- let Some(code) = self.tab_code_mut() else {
- return false;
- };
- let Some(ix) = code
- .files
- .iter()
- .position(|f| f.host.id() == host && f.path == *path)
- else {
- return false;
- };
- code.visible = true;
- let f = code.files.remove(ix);
- code.files.insert(0, f);
- code.active = 0;
- self.focus_editor(window, cx);
- self.apply_pending_cursor(host, path, path, window, cx);
- cx.notify();
- true
- }
-
- fn editor_install_file(
- &mut self,
- host: SharedHost,
- path: PathBuf,
- text: String,
- mtime: Option,
+ tab: TabId,
+ id: BufferId,
+ front: bool,
window: &mut Window,
cx: &mut Context,
) {
- let host_id = host.id();
- if self.editor_activate_open(host_id, &path, window, cx) {
+ let Some(tab_ix) = self.tab_index_of(tab).or(Some(self.active)) else {
+ return;
+ };
+ let Some(t) = self.tabs.get_mut(tab_ix) else {
+ return;
+ };
+ let code = t.code.get_or_insert_with(|| Box::new(TabCode::new()));
+ if !front {
+ // Listed, not brought forward: the one in front stays in front.
+ code.append(id);
+ cx.notify();
return;
}
- if self.tabs.get(self.active).is_none() {
- return;
+ code.show(id);
+ code.visible = true;
+ if tab_ix == self.active {
+ self.editor.bar = None;
+ self.focus_editor(window, cx);
}
- let language = language_for_path(&path);
+ cx.notify();
+ }
+
+ /// Makes a buffer out of a loaded file, or finds the one already open for
+ /// it, and shows it in `tab`.
+ fn editor_install(
+ &mut self,
+ host: SharedHost,
+ loaded: Loaded,
+ tab: TabId,
+ front: bool,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> BufferId {
+ if let Some(id) = self.buffer_at(host.id(), &loaded.path) {
+ self.editor_show_in_tab(tab, id, front, window, cx);
+ return id;
+ }
+ let language = language_for_path(&loaded.path);
+ let indent = loaded
+ .config
+ .indent(editor_text::detect_indent(&loaded.text, language));
+ let id = self.editor_new_buffer(
+ host,
+ loaded.path,
+ None,
+ loaded.text,
+ loaded.format,
+ loaded.config,
+ indent,
+ loaded.mtime,
+ window,
+ cx,
+ );
+ self.editor_show_in_tab(tab, id, front, window, cx);
+ self.editor_rebuild_watcher(cx);
+ id
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ fn editor_new_buffer(
+ &mut self,
+ host: SharedHost,
+ path: PathBuf,
+ untitled: Option,
+ text: String,
+ format: TextFormat,
+ config: EditorConfig,
+ indent: Indent,
+ mtime: Option,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> BufferId {
+ let language = if untitled.is_some() {
+ "text"
+ } else {
+ language_for_path(&path)
+ };
let (wrap, preview) = {
let cfg = cx.global::();
(
@@ -727,14 +1154,12 @@ impl Tty7App {
cfg.editor_markdown_preview && language == "markdown",
)
};
+ let saved = Fingerprint::of_str(&text);
let input = cx.new(|cx| {
InputState::new(window, cx)
.code_editor(language)
.multi_line(true)
- .tab_size(TabSize {
- tab_size: 4,
- hard_tabs: false,
- })
+ .tab_size(tab_size(indent))
.line_number(true)
.searchable(true)
.replaceable(true)
@@ -742,59 +1167,87 @@ impl Tty7App {
.soft_wrap(wrap)
.default_value(text)
});
- let sub = cx.subscribe_in(&input, window, {
- let path = path.clone();
+ let id = input.entity_id();
+ let sub = cx.subscribe_in(
+ &input,
+ window,
move |this: &mut Tty7App, _input, ev, _window, cx| {
if matches!(ev, InputEvent::Change) {
- let Some(f) = this
- .tabs
- .iter_mut()
- .filter_map(|t| t.code.as_deref_mut())
- .flat_map(|c| c.files.iter_mut())
- .find(|f| f.host.id() == host_id && f.path == path)
- else {
- return;
- };
- f.dirty = true;
- f.edit_seq = f.edit_seq.wrapping_add(1);
- cx.notify();
+ this.editor_note_edit(id, cx);
}
- }
- });
- let tab = self
- .tabs
- .get_mut(self.active)
- .expect("checked at function entry");
- let code = tab.code.get_or_insert_with(|| Box::new(TabCode::new()));
- let observe = cx.observe(&input, |_, _, cx| cx.notify());
- code.files.insert(
- 0,
- OpenFile {
- path,
- host,
- input,
- dirty: false,
- disk_mtime: mtime,
- edit_seq: 0,
- saving: None,
- save_pending: false,
- save_then_close: false,
- reload_seq: 0,
- conflict: false,
- preview,
- wrap,
- preview_scroll: gpui::ScrollHandle::new(),
- _sub: sub,
- _observe: observe,
},
);
- code.active = 0;
- code.visible = true;
- self.editor_rebuild_watcher(cx);
- self.focus_editor(window, cx);
+ let observe = cx.observe(&input, |_, _, cx| cx.notify());
+ self.editor.buffers.push(OpenFile {
+ path,
+ untitled,
+ host,
+ input,
+ dirty: false,
+ saved,
+ format,
+ saved_format: format,
+ config,
+ indent,
+ disk_mtime: mtime,
+ saving: false,
+ save_pending: false,
+ save_then_close: false,
+ reload_seq: 0,
+ conflict: None,
+ preview,
+ wrap,
+ preview_scroll: gpui::ScrollHandle::new(),
+ _sub: sub,
+ _observe: observe,
+ });
+ id
+ }
+
+ /// Re-derives whether a buffer differs from what is on disk, after an
+ /// edit of any kind — typing, undo, a reload, a save rule.
+ fn editor_note_edit(&mut self, id: BufferId, cx: &mut Context) {
+ let Some(f) = self.buffer(id) else {
+ return;
+ };
+ let state = f.input.read(cx);
+ let text = state.text();
+ let len = text.len();
+ let same_text = len == f.saved.len && Fingerprint::of_chunks(len, text.chunks()) == f.saved;
+ let dirty = !same_text || f.format != f.saved_format;
+ if let Some(f) = self.buffer_mut(id) {
+ f.dirty = dirty;
+ }
cx.notify();
}
+ /// A new, empty, never-saved buffer, on the window's own machine.
+ pub(crate) fn editor_new_file(&mut self, window: &mut Window, cx: &mut Context) {
+ let Some(host) = self.active_host(cx) else {
+ return;
+ };
+ let Some(tab) = self.tabs.get(self.active).map(|t| t.tree_id.get()) else {
+ return;
+ };
+ self.raise_code_overlay();
+ let n = self.editor.next_untitled;
+ self.editor.next_untitled += 1;
+ let indent = editor_text::detect_indent("", "text");
+ let id = self.editor_new_buffer(
+ host,
+ PathBuf::new(),
+ Some(n),
+ String::new(),
+ TextFormat::default(),
+ EditorConfig::default(),
+ indent,
+ None,
+ window,
+ cx,
+ );
+ self.editor_show_in_tab(tab, id, true, window, cx);
+ }
+
pub(crate) fn toggle_code_panel(&mut self, window: &mut Window, cx: &mut Context) {
let Some(tab) = self.tabs.get_mut(self.active) else {
return;
@@ -814,6 +1267,7 @@ impl Tty7App {
let code = tab.code.get_or_insert_with(|| Box::new(TabCode::new()));
if code.visible {
code.visible = false;
+ self.editor.bar = None;
self.file_tree.editing = None;
self.focus_active(window, cx);
cx.notify();
@@ -821,7 +1275,7 @@ impl Tty7App {
}
code.visible = true;
self.file_tree_refresh_roots(window, cx);
- if self.tab_code().is_some_and(|c| c.active_file().is_some()) {
+ if self.active_buffer().is_some() {
self.focus_editor(window, cx);
} else {
// With no file to show, the panel says "Open a file from the file
@@ -844,11 +1298,30 @@ impl Tty7App {
}
fn focus_editor(&self, window: &mut Window, cx: &mut Context) {
- if let Some(f) = self.tab_code().and_then(|c| c.active_file()) {
+ if let Some(f) = self.active_buffer() {
f.input.update(cx, |input, cx| input.focus(window, cx));
}
}
+ /// Brings the file at `pos` in the active tab's strip to the front.
+ pub(crate) fn editor_activate(
+ &mut self,
+ pos: usize,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ let Some(code) = self.tab_code_mut() else {
+ return;
+ };
+ if pos >= code.files.len() {
+ return;
+ }
+ code.active = pos;
+ self.editor.bar = None;
+ self.focus_editor(window, cx);
+ cx.notify();
+ }
+
/// The status bar's Preview / Edit button, and `ToggleDocumentPreview`.
/// Acts on the active tab's open file, and only when that file is
/// Markdown and the code panel is on screen — anything else has no
@@ -858,10 +1331,13 @@ impl Tty7App {
if !self.code_panel_visible() {
return;
}
- let Some(f) = self.tab_code_mut().and_then(|c| c.active_file_mut()) else {
+ let Some(id) = self.tab_code().and_then(TabCode::active_id) else {
return;
};
- if language_for_path(&f.path) != "markdown" {
+ let Some(f) = self.buffer_mut(id) else {
+ return;
+ };
+ if f.language() != "markdown" {
return;
}
f.preview = !f.preview;
@@ -877,7 +1353,10 @@ impl Tty7App {
if !self.code_panel_visible() {
return;
}
- let Some(f) = self.tab_code_mut().and_then(|c| c.active_file_mut()) else {
+ let Some(id) = self.tab_code().and_then(TabCode::active_id) else {
+ return;
+ };
+ let Some(f) = self.buffer_mut(id) else {
return;
};
f.wrap = !f.wrap;
@@ -889,144 +1368,525 @@ impl Tty7App {
cx.notify();
}
+ /// The status bar's line-ending button: flips the file between LF and
+ /// CRLF. The buffer itself always holds `\n`; this only changes what a
+ /// save writes, so it marks the file unsaved without touching its text.
+ fn toggle_line_ending(&mut self, cx: &mut Context) {
+ let Some(id) = self.tab_code().and_then(TabCode::active_id) else {
+ return;
+ };
+ let Some(f) = self.buffer_mut(id) else {
+ return;
+ };
+ f.format.line_ending = match f.format.line_ending {
+ LineEnding::Lf => LineEnding::CrLf,
+ LineEnding::CrLf => LineEnding::Lf,
+ };
+ self.editor_note_edit(id, cx);
+ }
+
pub(crate) fn editor_has_focus(&self, window: &Window, cx: &Context) -> bool {
self.code_panel_visible()
- && self
- .tab_code()
- .and_then(|c| c.active_file())
- .is_some_and(|f| {
- f.input
- .read(cx)
- .focus_handle(cx)
- .contains_focused(window, cx)
- })
+ && self.active_buffer().is_some_and(|f| {
+ f.input
+ .read(cx)
+ .focus_handle(cx)
+ .contains_focused(window, cx)
+ })
+ }
+
+ /// Whether anything in the editor panel — the text, its search bar, the
+ /// go-to-line prompt — has the focus. Wider than
+ /// [`Self::editor_has_focus`], for keys that belong to the panel as a
+ /// whole: ⌘S from the find box should still save.
+ pub(crate) fn editor_panel_has_focus(&self, window: &Window, cx: &Context) -> bool {
+ self.editor_has_focus(window, cx)
+ || self.editor.bar.as_ref().is_some_and(|b| {
+ b.input
+ .read(cx)
+ .focus_handle(cx)
+ .contains_focused(window, cx)
+ })
}
pub(crate) fn editor_save_active(&mut self, window: &mut Window, cx: &mut Context) {
- let Some(id) = self
- .tab_code()
- .and_then(|c| c.active_file())
- .map(|f| f.input.entity_id())
- else {
+ let Some(id) = self.tab_code().and_then(TabCode::active_id) else {
return;
};
- self.editor_save_file(id, false, window, cx);
+ self.editor_save_file(id, false, false, window, cx);
+ }
+
+ pub(crate) fn editor_save_as_active(&mut self, window: &mut Window, cx: &mut Context) {
+ let Some(id) = self.tab_code().and_then(TabCode::active_id) else {
+ return;
+ };
+ self.editor_save_as(id, false, window, cx);
+ }
+
+ /// Applies the file's `.editorconfig` save rules — trailing whitespace,
+ /// final newline — to the buffer itself, before it is written, so what is
+ /// on screen is what is on disk.
+ fn apply_save_rules(&mut self, id: BufferId, window: &mut Window, cx: &mut Context) {
+ let Some(f) = self.buffer(id) else {
+ return;
+ };
+ let text = f.input.read(cx).text().to_string();
+ if let Some(fixed) = editor_text::apply_save_rules(&text, &f.config) {
+ let input = f.input.clone();
+ replace_buffer_text(&input, &fixed, window, cx);
+ }
}
fn editor_save_file(
&mut self,
- id: gpui::EntityId,
+ id: BufferId,
then_close: bool,
+ force: bool,
window: &mut Window,
cx: &mut Context,
) {
- // The file's own host, not the active one: the buffer keeps pointing
- // at the machine it was read from, however the focus has moved since.
- let Some(host) = self.editor_file_mut(id).map(|f| f.host.clone()) else {
+ let Some(f) = self.buffer_mut(id) else {
return;
};
- let Some(f) = self.editor_file_mut(id) else {
+ if f.untitled.is_some() {
+ self.editor_save_as(id, then_close, window, cx);
return;
- };
+ }
f.save_then_close |= then_close;
- if f.saving.is_some() {
+ if f.saving {
f.save_pending = true;
return;
}
- let seq = f.edit_seq;
- f.saving = Some(seq);
+ self.apply_save_rules(id, window, cx);
+ let Some(f) = self.buffer_mut(id) else {
+ return;
+ };
let text = f.input.read(cx).text().to_string();
+ // Pasted text can carry its own `\r\n`; the buffer's line ending is
+ // the file's, and encoding adds it back uniformly.
+ let text = if text.contains("\r\n") {
+ text.replace("\r\n", "\n")
+ } else {
+ text
+ };
+ let bytes = match editor_text::encode(&text, &f.format) {
+ Ok(bytes) => bytes,
+ Err(e) => {
+ self.editor_offer_utf8(id, then_close, e.unmappable, window, cx);
+ return;
+ }
+ };
+ let Some(f) = self.buffer_mut(id) else {
+ return;
+ };
+ f.saving = true;
+ let expect = match (force, f.conflict) {
+ (true, _) | (_, Some(DiskConflict::Deleted)) => None,
+ _ => Some(f.disk_mtime),
+ };
+ let written = Fingerprint::of_str(&text);
+ let format = f.format;
+ let host = f.host.clone();
let target = f.path.clone();
let host_id = host.id();
- let saved_in = target.parent().map(std::path::Path::to_path_buf);
+ let saved_in = target.parent().map(Path::to_path_buf);
HostOps::run_in(
host,
window,
cx,
- move |h| h.write_file(&target, text.as_bytes()).map(|m| m.mtime),
- move |app, result: std::io::Result>, window, cx| {
- let Some(f) = app.editor_file_mut(id) else {
+ move |h| write_checked(h, &target, &bytes, expect),
+ move |app, outcome: SaveOutcome, window, cx| {
+ let Some(f) = app.buffer_mut(id) else {
return;
};
- f.saving = None;
- let landing = settle_save(
- result.is_ok(),
- seq,
- f.edit_seq,
- std::mem::take(&mut f.save_pending),
- );
- let wrote = result.is_ok();
- match result {
- Ok(mtime) => {
+ f.saving = false;
+ let pending = std::mem::take(&mut f.save_pending);
+ match outcome {
+ SaveOutcome::Saved(mtime) => {
f.disk_mtime = mtime;
+ f.saved = written;
+ f.saved_format = format;
+ f.conflict = None;
+ app.editor_note_edit(id, cx);
+ // A save is a working-tree edit the `.git` watch cannot
+ // see, and the file tree only sees it while it happens
+ // to be showing that directory.
+ if let Some(dir) = &saved_in {
+ app.scm_invalidate_cwd(host_id, dir, cx);
+ }
+ if pending {
+ app.editor_save_file(id, false, false, window, cx);
+ return;
+ }
+ let close = app
+ .buffer_mut(id)
+ .is_some_and(|f| std::mem::take(&mut f.save_then_close) && !f.dirty);
+ if close {
+ app.editor_drop_buffer(id, cx);
+ }
+ app.editor_saves_landed(window, cx);
}
- Err(e) => {
+ SaveOutcome::Conflict(observed) => {
+ f.save_then_close = false;
+ f.conflict = Some(DiskConflict::Changed(observed));
+ app.editor_saves_failed(id);
+ app.editor_ask_overwrite(id, window, cx);
+ }
+ SaveOutcome::Failed(e) => {
+ f.save_then_close = false;
// "Save failed" did not say which file, and with more
// than one editor tab open that is the first thing you
// need to know.
- let name = f
- .path
- .file_name()
- .map(|n| n.to_string_lossy().to_string())
- .unwrap_or_else(|| f.path.display().to_string());
- let context = t_fmt(L10nKey::EditorSaveFailed, &[("name", &name)]);
+ let context = t_fmt(L10nKey::EditorSaveFailed, &[("name", &f.label())]);
HostOps::notify_err(window, cx, &context, &e);
+ app.editor_saves_failed(id);
}
}
- if landing.clean {
- f.dirty = false;
- f.conflict = false;
- }
- // A save is a working-tree edit the `.git` watch cannot see,
- // and the file tree only sees it while it happens to be showing
- // that directory.
- if wrote && let Some(dir) = &saved_in {
- app.scm_invalidate_cwd(host_id, dir, cx);
- }
- if landing.requeue {
- app.editor_save_file(id, false, window, cx);
- cx.notify();
- return;
- }
- let close = app
- .editor_file_mut(id)
- .is_some_and(|f| std::mem::take(&mut f.save_then_close) && !f.dirty);
- if close && let Some((tab_ix, ix)) = app.editor_file_position(id) {
- app.editor_remove_file_in(tab_ix, ix, cx);
- }
cx.notify();
},
);
cx.notify();
}
- fn editor_file_mut(&mut self, id: gpui::EntityId) -> Option<&mut OpenFile> {
- self.tabs
- .iter_mut()
- .filter_map(|t| t.code.as_deref_mut())
- .flat_map(|c| c.files.iter_mut())
- .find(|f| f.input.entity_id() == id)
- }
-
- fn editor_file_position(&self, id: gpui::EntityId) -> Option<(usize, usize)> {
- self.tabs.iter().enumerate().find_map(|(tab_ix, t)| {
- let code = t.code.as_deref()?;
- let ix = code.files.iter().position(|f| f.input.entity_id() == id)?;
- Some((tab_ix, ix))
+ /// Asked when a save finds the file changed underneath it.
+ fn editor_ask_overwrite(&mut self, id: BufferId, window: &mut Window, cx: &mut Context) {
+ let Some(f) = self.buffer(id) else {
+ return;
+ };
+ let name = f.label();
+ let answer = window.prompt(
+ PromptLevel::Warning,
+ &t_fmt(L10nKey::EditorSaveConflictTitle, &[("name", &name)]),
+ Some(t(L10nKey::EditorSaveConflictBody)),
+ &crate::ui::confirm_answers(t(L10nKey::EditorOverwrite), t(L10nKey::Cancel)),
+ cx,
+ );
+ cx.spawn_in(window, async move |app, cx| {
+ if !matches!(answer.await, Ok(0)) {
+ return;
+ }
+ let _ = app.update_in(cx, |app, window, cx| {
+ app.editor_save_file(id, false, true, window, cx);
+ });
})
+ .detach();
}
- pub(crate) fn editor_close_file(
+ /// Asked when the text holds a character the file's encoding cannot.
+ fn editor_offer_utf8(
&mut self,
- ix: usize,
+ id: BufferId,
+ then_close: bool,
+ unmappable: char,
window: &mut Window,
cx: &mut Context,
) {
- let Some(f) = self.tab_code().and_then(|c| c.files.get(ix)) else {
+ let Some(f) = self.buffer_mut(id) else {
return;
};
- if !f.dirty {
- self.editor_remove_file(ix, cx);
+ f.save_then_close = false;
+ let name = f.label();
+ let encoding = f.format.encoding_label();
+ self.editor_saves_failed(id);
+ let answer = window.prompt(
+ PromptLevel::Warning,
+ &t_fmt(
+ L10nKey::EditorEncodeFailedTitle,
+ &[("name", &name), ("encoding", encoding)],
+ ),
+ Some(&t_fmt(
+ L10nKey::EditorEncodeFailedBody,
+ &[("ch", &unmappable.to_string()), ("encoding", encoding)],
+ )),
+ &crate::ui::confirm_answers(t(L10nKey::EditorSaveAsUtf8), t(L10nKey::Cancel)),
+ cx,
+ );
+ cx.spawn_in(window, async move |app, cx| {
+ if !matches!(answer.await, Ok(0)) {
+ return;
+ }
+ let _ = app.update_in(cx, |app, window, cx| {
+ if let Some(f) = app.buffer_mut(id) {
+ f.format = TextFormat {
+ line_ending: f.format.line_ending,
+ ..TextFormat::default()
+ };
+ }
+ app.editor_save_file(id, then_close, false, window, cx);
+ });
+ })
+ .detach();
+ }
+
+ /// Save As: the native panel for a file on this machine, the path bar for
+ /// one on a machine the panel cannot browse.
+ fn editor_save_as(
+ &mut self,
+ id: BufferId,
+ then_close: bool,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ let Some(f) = self.buffer(id) else {
+ return;
+ };
+ let suggested = if f.untitled.is_some() {
+ PathBuf::from(format!("{}.txt", f.label()))
+ } else {
+ PathBuf::from(f.label().to_string())
+ };
+ let dir = match f.path.parent() {
+ Some(dir) if f.untitled.is_none() => dir.to_path_buf(),
+ _ => self
+ .tab_code()
+ .and_then(|c| c.roots.first().cloned())
+ .unwrap_or_default(),
+ };
+ if !f.host.id().is_local() {
+ let start = if dir.as_os_str().is_empty() {
+ suggested
+ } else {
+ f.host.join(&dir, &suggested.to_string_lossy())
+ };
+ self.editor_open_bar(
+ BarKind::SaveAs { id, then_close },
+ start.display().to_string(),
+ window,
+ cx,
+ );
+ return;
+ }
+ let dir = if dir.as_os_str().is_empty() {
+ dirs_home()
+ } else {
+ dir
+ };
+ let rx = cx.prompt_for_new_path(&dir, suggested.to_str());
+ cx.spawn_in(window, async move |app, cx| {
+ let Ok(Ok(Some(path))) = rx.await else {
+ // Cancelled: a close that was waiting for this save must not
+ // go ahead on some later, unrelated one.
+ let _ = app.update(cx, |app, _cx| app.editor_saves_failed(id));
+ return;
+ };
+ let _ = app.update_in(cx, |app, window, cx| {
+ app.editor_save_to(id, path, then_close, window, cx);
+ });
+ })
+ .detach();
+ }
+
+ /// Writes a buffer to a new path and points it there from then on.
+ fn editor_save_to(
+ &mut self,
+ id: BufferId,
+ path: PathBuf,
+ then_close: bool,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ let Some(f) = self.buffer(id) else {
+ return;
+ };
+ let host = f.host.clone();
+ if let Some(other) = self.buffer_at(host.id(), &path)
+ && other != id
+ {
+ window.push_notification(
+ t_fmt(
+ L10nKey::EditorAlreadyOpen,
+ &[("path", &path.display().to_string())],
+ ),
+ cx,
+ );
+ return;
+ }
+ if let Some(f) = self.buffer_mut(id) {
+ f.path = path.clone();
+ f.untitled = None;
+ f.conflict = None;
+ // A new file keeps the format the buffer had; its own
+ // `.editorconfig` is read the next time it opens.
+ let language = language_for_path(&path);
+ f.input
+ .update(cx, |st, cx| st.set_highlighter(language, cx));
+ }
+ self.editor_rebuild_watcher(cx);
+ // Written as a save to a file already known to be the one intended:
+ // the native panel has asked about replacing an existing file, and
+ // the path bar asks before it gets here.
+ self.editor_save_file(id, then_close, true, window, cx);
+ }
+
+ fn editor_open_bar(
+ &mut self,
+ kind: BarKind,
+ initial: String,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ let placeholder = match kind {
+ BarKind::GoToLine => {
+ let total = self
+ .active_buffer()
+ .map(|f| f.input.read(cx).text().lines_len())
+ .unwrap_or(1);
+ t_fmt(
+ L10nKey::EditorGoToLinePlaceholder,
+ &[("total", &total.to_string())],
+ )
+ }
+ BarKind::SaveAs { .. } => t(L10nKey::EditorSaveAsPlaceholder).to_string(),
+ };
+ let input = cx.new(|cx| {
+ InputState::new(window, cx)
+ .placeholder(placeholder)
+ .default_value(initial)
+ });
+ let sub = cx.subscribe_in(
+ &input,
+ window,
+ |this: &mut Tty7App, _input, ev, window, cx| {
+ match ev {
+ InputEvent::PressEnter { .. } => this.editor_submit_bar(window, cx),
+ // Clicking away from a go-to-line box is how you say never
+ // mind. A Save As is left up: it may be half-typed.
+ InputEvent::Blur => {
+ if matches!(
+ this.editor.bar.as_ref().map(|b| &b.kind),
+ Some(BarKind::GoToLine)
+ ) {
+ this.editor.bar = None;
+ cx.notify();
+ }
+ }
+ _ => {}
+ }
+ },
+ );
+ input.update(cx, |st, cx| st.focus(window, cx));
+ self.editor.bar = Some(EditorBar {
+ kind,
+ input,
+ _sub: sub,
+ });
+ cx.notify();
+ }
+
+ pub(crate) fn editor_go_to_line(&mut self, window: &mut Window, cx: &mut Context) {
+ if !self.code_panel_visible() || self.active_buffer().is_none() {
+ return;
+ }
+ if let Some(f) = self.tab_code().and_then(TabCode::active_id)
+ && let Some(f) = self.buffer_mut(f)
+ {
+ // There is no line to land on in the rendered view.
+ f.preview = false;
+ }
+ self.editor_open_bar(BarKind::GoToLine, String::new(), window, cx);
+ }
+
+ fn editor_close_bar(&mut self, window: &mut Window, cx: &mut Context) {
+ if let Some(BarKind::SaveAs { id, .. }) = self.editor.bar.as_ref().map(|b| &b.kind) {
+ let id = *id;
+ self.editor_saves_failed(id);
+ }
+ self.editor.bar = None;
+ self.focus_editor(window, cx);
+ cx.notify();
+ }
+
+ fn editor_submit_bar(&mut self, window: &mut Window, cx: &mut Context) {
+ let Some(bar) = self.editor.bar.as_ref() else {
+ return;
+ };
+ let text = bar.input.read(cx).value().to_string();
+ match bar.kind {
+ BarKind::GoToLine => {
+ let Some((line, column)) = parse_line_target(&text) else {
+ return;
+ };
+ self.editor_close_bar(window, cx);
+ let Some(f) = self.active_buffer() else {
+ return;
+ };
+ let input = f.input.clone();
+ let lines = input.read(cx).text().lines_len().max(1) as u32;
+ let position = Position {
+ line: line.min(lines) - 1,
+ character: column - 1,
+ };
+ place_cursor(input, position, CURSOR_SCROLL_ATTEMPTS, window, cx);
+ }
+ BarKind::SaveAs { id, then_close } => {
+ let path = PathBuf::from(text.trim());
+ if path.as_os_str().is_empty() {
+ return;
+ }
+ let Some(host) = self.buffer(id).map(|f| f.host.clone()) else {
+ return;
+ };
+ if !host.is_absolute(&path) {
+ return;
+ }
+ self.editor.bar = None;
+ cx.notify();
+ let check = path.clone();
+ HostOps::run_in(
+ host,
+ window,
+ cx,
+ move |h| h.exists(&check),
+ move |app, exists: bool, window, cx| {
+ if !exists {
+ app.editor_save_to(id, path, then_close, window, cx);
+ return;
+ }
+ let answer = window.prompt(
+ PromptLevel::Warning,
+ &t_fmt(
+ L10nKey::EditorReplaceExisting,
+ &[("path", &path.display().to_string())],
+ ),
+ None,
+ &crate::ui::confirm_answers(
+ t(L10nKey::EditorReplace),
+ t(L10nKey::Cancel),
+ ),
+ cx,
+ );
+ cx.spawn_in(window, async move |app, cx| {
+ if !matches!(answer.await, Ok(0)) {
+ return;
+ }
+ let _ = app.update_in(cx, |app, window, cx| {
+ app.editor_save_to(id, path, then_close, window, cx);
+ });
+ })
+ .detach();
+ },
+ );
+ }
+ }
+ }
+
+ /// Closes the file at `pos` in the active tab's strip. Asks first only
+ /// when this is the last tab showing a buffer with unsaved changes.
+ pub(crate) fn editor_close_file(
+ &mut self,
+ pos: usize,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ let tab_ix = self.active;
+ let Some(id) = self.tab_code().and_then(|c| c.files.get(pos).copied()) else {
+ return;
+ };
+ let Some(f) = self.buffer(id) else {
+ return;
+ };
+ if !f.dirty || self.buffer_refs(id) > 1 {
+ self.editor_remove_from_tab(tab_ix, id, cx);
return;
}
let name = f.label();
@@ -1048,16 +1908,11 @@ impl Tty7App {
],
cx,
);
- let id = f.input.entity_id();
cx.spawn_in(window, async move |app, cx| {
let Ok(choice) = answer.await else { return };
let _ = app.update_in(cx, |app, window, cx| match choice {
- 0 => app.editor_save_file(id, true, window, cx),
- 2 => {
- if let Some((tab_ix, ix)) = app.editor_file_position(id) {
- app.editor_remove_file_in(tab_ix, ix, cx);
- }
- }
+ 0 => app.editor_save_file(id, true, false, window, cx),
+ 2 => app.editor_drop_buffer(id, cx),
_ => {}
});
})
@@ -1069,7 +1924,7 @@ impl Tty7App {
window: &mut Window,
cx: &mut Context,
) -> bool {
- if !self.editor_has_focus(window, cx) {
+ if !self.editor_panel_has_focus(window, cx) {
return false;
}
let Some(code) = self.tab_code_mut() else {
@@ -1085,36 +1940,235 @@ impl Tty7App {
true
}
- fn editor_remove_file(&mut self, ix: usize, cx: &mut Context) {
- self.editor_remove_file_in(self.active, ix, cx);
- }
-
- fn editor_remove_file_in(&mut self, tab_ix: usize, ix: usize, cx: &mut Context) {
- let Some(code) = self
+ /// Takes a buffer out of one tab's strip, and drops it once no tab shows
+ /// it any more.
+ fn editor_remove_from_tab(&mut self, tab_ix: usize, id: BufferId, cx: &mut Context) {
+ if let Some(code) = self
.tabs
.get_mut(tab_ix)
.and_then(|t| t.code.as_deref_mut())
- else {
- return;
- };
- if ix >= code.files.len() {
- return;
+ {
+ code.forget(id);
}
- code.files.remove(ix);
- if code.active >= ix && code.active > 0 {
- code.active -= 1;
+ if self.buffer_refs(id) == 0 {
+ self.editor_drop_buffer(id, cx);
+ }
+ cx.notify();
+ }
+
+ /// Forgets a buffer everywhere, edits and all.
+ fn editor_drop_buffer(&mut self, id: BufferId, cx: &mut Context) {
+ // Whatever was waiting on this buffer to be saved is not going to
+ // see that happen now.
+ self.editor_saves_failed(id);
+ for code in self.tabs.iter_mut().filter_map(|t| t.code.as_deref_mut()) {
+ code.forget(id);
+ }
+ self.editor.buffers.retain(|b| b.id() != id);
+ if matches!(
+ self.editor.bar.as_ref().map(|b| &b.kind),
+ Some(BarKind::SaveAs { id: bar_id, .. }) if *bar_id == id
+ ) {
+ self.editor.bar = None;
}
self.editor_rebuild_watcher(cx);
cx.notify();
}
+ // ---- Unsaved changes standing in the way of a close ----
+
+ /// Asks about unsaved buffers before `then` goes ahead. Returns `true`
+ /// when there was something to ask about: the caller stops there, and the
+ /// answer carries the close on — after saving, after discarding, or not
+ /// at all.
+ pub(crate) fn editor_guard_unsaved(
+ &mut self,
+ ids: Vec,
+ then: AfterUnsaved,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> bool {
+ if ids.is_empty() {
+ return false;
+ }
+ if self.editor.unsaved_prompt_open {
+ return true;
+ }
+ self.editor.unsaved_prompt_open = true;
+ let names: Vec = ids
+ .iter()
+ .filter_map(|id| self.buffer(*id).map(OpenFile::label))
+ .collect();
+ let title = match names.as_slice() {
+ [one] => t_fmt(L10nKey::EditorUnsavedChanges, &[("name", one)]),
+ _ => t_fmt(
+ L10nKey::EditorUnsavedChangesMany,
+ &[("count", &names.len().to_string())],
+ ),
+ };
+ let mut body: Vec = names.iter().take(8).map(|n| n.to_string()).collect();
+ if names.len() > 8 {
+ body.push("…".into());
+ }
+ let body = (names.len() > 1).then(|| body.join("\n"));
+ let answer = window.prompt(
+ PromptLevel::Warning,
+ &title,
+ body.as_deref(),
+ // The same arrangement as closing a single file, and for the same
+ // reason: Discard as far from Return as the dialog allows.
+ &[
+ gpui::PromptButton::ok(if names.len() > 1 {
+ t(L10nKey::EditorSaveAll)
+ } else {
+ t(L10nKey::Save)
+ }),
+ gpui::PromptButton::cancel(t(L10nKey::Cancel)),
+ gpui::PromptButton::ok(t(L10nKey::EditorDiscard)),
+ ],
+ cx,
+ );
+ // Showing it is part of the question: the file being asked about may
+ // be behind the terminal, in a tab that is not the one in front.
+ if let Some(tab_ix) = ids.first().and_then(|id| {
+ self.tabs
+ .iter()
+ .position(|t| t.code.as_deref().is_some_and(|c| c.files.contains(id)))
+ }) && tab_ix == self.active
+ && let Some(code) = self.tab_code_mut()
+ && let Some(pos) = code.files.iter().position(|f| *f == ids[0])
+ {
+ code.active = pos;
+ code.visible = true;
+ }
+ cx.spawn_in(window, async move |app, cx| {
+ let choice = answer.await;
+ let _ = app.update_in(cx, |app, window, cx| {
+ app.editor.unsaved_prompt_open = false;
+ match choice {
+ Ok(0) => {
+ app.editor.waiters.push(SaveWaiter {
+ ids: ids.clone(),
+ then,
+ });
+ for id in ids {
+ app.editor_save_file(id, false, false, window, cx);
+ }
+ }
+ Ok(2) => {
+ for id in ids {
+ app.editor_drop_buffer(id, cx);
+ }
+ app.editor_continue(then, window, cx);
+ }
+ _ => cx.notify(),
+ }
+ });
+ })
+ .detach();
+ true
+ }
+
+ /// Runs whatever close was waiting on saves that have now all landed.
+ fn editor_saves_landed(&mut self, window: &mut Window, cx: &mut Context) {
+ let (ready, waiting): (Vec, Vec) =
+ std::mem::take(&mut self.editor.waiters)
+ .into_iter()
+ .partition(|w| {
+ w.ids.iter().all(|id| {
+ self.buffer(*id)
+ .is_none_or(|f| !f.dirty && !f.saving && f.untitled.is_none())
+ })
+ });
+ self.editor.waiters = waiting;
+ for w in ready {
+ self.editor_continue(w.then, window, cx);
+ }
+ }
+
+ /// A save that was part of a close did not happen, so neither does the
+ /// close: whatever it would have thrown away is still unsaved.
+ fn editor_saves_failed(&mut self, id: BufferId) {
+ self.editor.waiters.retain(|w| !w.ids.contains(&id));
+ }
+
+ fn editor_continue(&mut self, then: AfterUnsaved, window: &mut Window, cx: &mut Context) {
+ match then {
+ AfterUnsaved::CloseTab(tab) => {
+ if let Some(ix) = self.tab_index_of(tab) {
+ self.close_tab(ix, window, cx);
+ }
+ }
+ AfterUnsaved::ClosePane => self.close_pane_after_unsaved(window, cx),
+ AfterUnsaved::CloseWindow => self.close_window_after_unsaved(window, cx),
+ AfterUnsaved::Quit => self.quit_after_unsaved(window, cx),
+ }
+ }
+
+ // ---- The file tree moving files out from under their buffers ----
+
+ /// A file or folder was renamed through tty7: buffers at or under the old
+ /// path follow it. Without this the buffer kept the old name, and the next
+ /// save wrote a second copy there.
+ pub(crate) fn editor_path_moved(
+ &mut self,
+ host: HostId,
+ from: &Path,
+ to: &Path,
+ cx: &mut Context,
+ ) {
+ let mut moved = false;
+ for f in self.editor.buffers.iter_mut() {
+ if f.untitled.is_some() || f.host.id() != host {
+ continue;
+ }
+ let Ok(rest) = f.path.strip_prefix(from) else {
+ continue;
+ };
+ f.path = if rest.as_os_str().is_empty() {
+ to.to_path_buf()
+ } else {
+ to.join(rest)
+ };
+ let language = language_for_path(&f.path);
+ f.input
+ .update(cx, |st, cx| st.set_highlighter(language, cx));
+ moved = true;
+ }
+ if moved {
+ self.editor_rebuild_watcher(cx);
+ cx.notify();
+ }
+ }
+
+ /// A file or folder was deleted through tty7: its buffers say so rather
+ /// than waiting for the watcher, which a remote host may not have.
+ pub(crate) fn editor_path_removed(
+ &mut self,
+ host: HostId,
+ path: &Path,
+ cx: &mut Context,
+ ) {
+ for f in self.editor.buffers.iter_mut() {
+ if f.untitled.is_none() && f.host.id() == host && f.path.starts_with(path) {
+ f.conflict = Some(DiskConflict::Deleted);
+ f.disk_mtime = None;
+ }
+ }
+ cx.notify();
+ }
+
+ // ---- Changes made by something else ----
+
pub(crate) fn editor_handle_external_change(
&mut self,
path: &Path,
window: &mut Window,
cx: &mut Context,
) {
- let Some(host) = self.active_host(cx) else {
+ // The watcher's own host: the events came from its machine, whatever
+ // the window has moved on to since.
+ let Some(host) = self.editor.watch_host.clone() else {
return;
};
let host_id = host.id();
@@ -1124,9 +2178,15 @@ impl Tty7App {
host,
window,
cx,
- move |h| h.stat(&p).ok().and_then(|m| m.mtime),
- move |app, mtime, window, cx| {
- app.editor_apply_external_change(host_id, &landed, mtime, window, cx)
+ move |h| match h.stat(&p) {
+ Ok(m) => Observed::Present(m.mtime),
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Observed::Missing,
+ // Unreadable for some other reason: say nothing rather than
+ // call a file deleted that may well still be there.
+ Err(_) => Observed::Present(None),
+ },
+ move |app, observed, window, cx| {
+ app.editor_apply_external_change(host_id, &landed, observed, window, cx)
},
);
}
@@ -1135,56 +2195,48 @@ impl Tty7App {
&mut self,
host: HostId,
path: &Path,
- mtime: Option,
+ observed: Observed,
window: &mut Window,
cx: &mut Context,
) {
- let mut reload: Vec<(usize, usize)> = Vec::new();
- let mut changed = false;
- for (tab_ix, tab) in self.tabs.iter_mut().enumerate() {
- let Some(code) = tab.code.as_deref_mut() else {
- continue;
- };
- for (ix, f) in code.files.iter_mut().enumerate() {
- if f.host.id() != host || f.path != *path {
- continue;
- }
- match classify_external_change(f.saving.is_some(), f.dirty, f.disk_mtime, mtime) {
- ExternalChange::Ignore => {}
- ExternalChange::Conflict => {
- f.conflict = true;
- changed = true;
- }
- ExternalChange::Reload => reload.push((tab_ix, ix)),
- }
+ let Some(id) = self.buffer_at(host, path) else {
+ return;
+ };
+ let Some(f) = self.buffer_mut(id) else {
+ return;
+ };
+ match classify_external_change(f.saving, f.dirty, f.disk_mtime, observed) {
+ ExternalChange::Ignore => {}
+ ExternalChange::Conflict => {
+ let Observed::Present(mtime) = observed else {
+ return;
+ };
+ f.conflict = Some(DiskConflict::Changed(mtime));
+ cx.notify();
}
- }
- for (tab_ix, ix) in reload {
- self.editor_reload_from_disk(tab_ix, ix, window, cx);
- }
- if changed {
- cx.notify();
+ ExternalChange::Deleted => {
+ f.conflict = Some(DiskConflict::Deleted);
+ f.disk_mtime = None;
+ cx.notify();
+ }
+ ExternalChange::Reload => self.editor_reload_from_disk(id, window, cx),
}
}
pub(crate) fn editor_reload_from_disk(
&mut self,
- tab_ix: usize,
- ix: usize,
+ id: BufferId,
window: &mut Window,
cx: &mut Context,
) {
- let Some(f) = self
- .tabs
- .get_mut(tab_ix)
- .and_then(|t| t.code.as_deref_mut())
- .and_then(|c| c.files.get_mut(ix))
- else {
+ let Some(f) = self.buffer_mut(id) else {
return;
};
+ if f.untitled.is_some() {
+ return;
+ }
let target = f.path.clone();
let host = f.host.clone();
- let id = f.input.entity_id();
f.reload_seq = f.reload_seq.wrapping_add(1);
let seq = f.reload_seq;
HostOps::run_in(
@@ -1193,35 +2245,261 @@ impl Tty7App {
cx,
move |h| {
let bytes = h.read_file(&target, MAX_FILE_BYTES)?;
- let text = String::from_utf8(bytes).map_err(|_| {
- std::io::Error::new(std::io::ErrorKind::InvalidData, "not valid UTF-8")
+ let decoded = editor_text::decode(&bytes).ok_or_else(|| {
+ std::io::Error::new(std::io::ErrorKind::InvalidData, "no longer text")
})?;
let mtime = h.stat(&target).ok().and_then(|m| m.mtime);
- Ok((text, mtime))
+ Ok((decoded, mtime))
},
- move |app, result: std::io::Result<(String, Option)>, window, cx| {
- let Some(f) = app.editor_file_mut(id) else {
+ move |app,
+ result: std::io::Result<(editor_text::Decoded, Option)>,
+ window,
+ cx| {
+ let Some(f) = app.buffer_mut(id) else {
return;
};
if f.reload_seq != seq {
return;
}
- let Ok((text, mtime)) = result else {
- f.dirty = true;
- f.conflict = false;
- cx.notify();
- return;
+ let (decoded, mtime) = match result {
+ Ok(ok) => ok,
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
+ f.conflict = Some(DiskConflict::Deleted);
+ f.disk_mtime = None;
+ cx.notify();
+ return;
+ }
+ // Unreadable for a moment — mid-write, say — is not a
+ // reason to touch the buffer; the next change event tries
+ // again.
+ Err(e) => {
+ log::warn!("editor: reload of {} failed: {e}", f.path.display());
+ return;
+ }
};
f.disk_mtime = mtime;
- f.dirty = false;
- f.conflict = false;
- f.edit_seq = f.edit_seq.wrapping_add(1);
+ f.conflict = None;
+ f.saved = Fingerprint::of_str(&decoded.text);
+ f.format = decoded.format;
+ f.saved_format = decoded.format;
let input = f.input.clone();
- input.update(cx, |input, cx| input.set_value(text, window, cx));
+ replace_buffer_text(&input, &decoded.text, window, cx);
+ // The replace reports its own change, but only once effects
+ // flush; settle it now so nothing reads a stale flag first.
+ app.editor_note_edit(id, cx);
+ },
+ );
+ }
+
+ // ---- Keeping the registry, the tabs and the session store in step ----
+
+ /// Once per frame: buffers no tab shows any more are dropped — or, if
+ /// they hold unsaved work, handed to the tab in front rather than lost —
+ /// the active tab gets back the files it had open last time, and any
+ /// tab whose files changed is recorded for next time.
+ ///
+ /// Done here rather than at each place a tab can go — a close, a shell
+ /// that exits, a tab dragged into another, a workspace switch, a server
+ /// restart — because this sees all of them, including ones added later.
+ pub(crate) fn editor_sync(&mut self, window: &mut Window, cx: &mut Context) {
+ // A tab that left and came back under the same id — a server restart
+ // rebuilds every tab that way — gets its files back like a relaunch.
+ if self.editor.restored.len() > self.tabs.len() {
+ let live: HashSet = self.tabs.iter().map(|t| t.tree_id.get()).collect();
+ self.editor.restored.retain(|id| live.contains(id));
+ }
+ self.editor_adopt_orphans(window, cx);
+ self.editor_restore_active(window, cx);
+ self.editor_record_sessions(cx);
+ }
+
+ fn editor_adopt_orphans(&mut self, window: &mut Window, cx: &mut Context) {
+ // No tabs at all is the gap in the middle of a server restart or a
+ // workspace switch, not a verdict on the buffers: wait for the tabs.
+ if self.editor.buffers.is_empty() || self.tabs.is_empty() {
+ return;
+ }
+ let shown: HashSet = self
+ .tabs
+ .iter()
+ .filter_map(|t| t.code.as_deref())
+ .flat_map(|c| c.files.iter().copied())
+ .collect();
+ let orphans: Vec<(BufferId, bool)> = self
+ .editor
+ .buffers
+ .iter()
+ .filter(|b| !shown.contains(&b.id()))
+ .map(|b| (b.id(), b.dirty))
+ .collect();
+ if orphans.is_empty() {
+ return;
+ }
+ let mut changed = false;
+ for (id, dirty) in orphans {
+ if !dirty {
+ self.editor.buffers.retain(|b| b.id() != id);
+ changed = true;
+ continue;
+ }
+ // Nowhere to put it (every tab gone mid-restart): keep it, and it
+ // is adopted on a later frame.
+ let Some(tab) = self.tabs.get(self.active).map(|t| t.tree_id.get()) else {
+ continue;
+ };
+ let name = self.buffer(id).map(OpenFile::label).unwrap_or_default();
+ self.editor_show_in_tab(tab, id, false, window, cx);
+ window.push_notification(t_fmt(L10nKey::EditorOrphanAdopted, &[("name", &name)]), cx);
+ changed = true;
+ }
+ if changed {
+ self.editor_rebuild_watcher(cx);
+ cx.notify();
+ }
+ }
+
+ fn editor_restore_active(&mut self, window: &mut Window, cx: &mut Context) {
+ let Some(tab) = self.tabs.get(self.active) else {
+ return;
+ };
+ let tab_id = tab.tree_id.get();
+ if !self.editor.restored.insert(tab_id) {
+ return;
+ }
+ if tab.code.as_deref().is_some_and(|c| !c.files.is_empty()) {
+ return;
+ }
+ let Some(state) = editor_session::get(cx, tab_id) else {
+ return;
+ };
+ if state.files.is_empty() {
+ return;
+ }
+ let Some(host) = self.active_host(cx) else {
+ return;
+ };
+ // What was recorded is what is being put back; recording it again
+ // before the files have loaded would write down an empty tab.
+ self.editor.recorded.insert(tab_id, state.clone());
+ let files = state.files.clone();
+ HostOps::run_in(
+ host.clone(),
+ window,
+ cx,
+ move |h| {
+ files
+ .into_iter()
+ .map(|p| load_file(h, p).ok())
+ .collect::>()
+ },
+ move |app, loaded: Vec>, window, cx| {
+ let front = loaded
+ .get(state.active)
+ .and_then(Option::as_ref)
+ .map(|l| l.path.clone());
+ let mut ids = Vec::new();
+ for l in loaded.into_iter().flatten() {
+ ids.push(app.editor_install(host.clone(), l, tab_id, false, window, cx));
+ }
+ let Some(tab_ix) = app.tab_index_of(tab_id) else {
+ return;
+ };
+ let Some(code) = app.tabs[tab_ix].code.as_deref_mut() else {
+ return;
+ };
+ if let Some(front) = front
+ && let Some(pos) = code.files.iter().position(|id| {
+ app.editor
+ .buffers
+ .iter()
+ .any(|b| b.id() == *id && b.path == front)
+ })
+ {
+ code.active = pos;
+ }
+ code.visible = state.visible && !code.files.is_empty();
+ if tab_ix == app.active && code.visible {
+ app.focus_editor(window, cx);
+ }
cx.notify();
},
);
}
+
+ fn editor_record_sessions(&mut self, cx: &mut Context) {
+ let spawn_host = self.spawn_host(cx);
+ let mut changed: Vec<(TabId, TabEditor)> = Vec::new();
+ for tab in &self.tabs {
+ let Some(code) = tab.code.as_deref() else {
+ continue;
+ };
+ let tab_id = tab.tree_id.get();
+ // A tab still waiting for its files to come back has nothing to
+ // say about them yet.
+ if !self.editor.restored.contains(&tab_id) {
+ continue;
+ }
+ let mut files = Vec::new();
+ let mut active = 0;
+ for (pos, id) in code.files.iter().enumerate() {
+ // Only files on the window's own machine: an SFTP buffer's
+ // host is a connection that will not exist next launch.
+ let Some(f) = self.buffer(*id) else { continue };
+ if f.untitled.is_some() || f.host.id() != spawn_host {
+ continue;
+ }
+ if pos == code.active {
+ active = files.len();
+ }
+ files.push(f.path.clone());
+ }
+ let state = TabEditor {
+ files,
+ active,
+ visible: code.visible,
+ };
+ if self.editor.recorded.get(&tab_id) != Some(&state) {
+ changed.push((tab_id, state));
+ }
+ }
+ for (tab_id, state) in changed {
+ self.editor.recorded.insert(tab_id, state.clone());
+ editor_session::put(cx, tab_id, state);
+ }
+ }
+
+ /// A tab is being closed: the files only it showed go with it. Unsaved
+ /// ones were asked about before the close got here — except when a shell
+ /// exiting took the tab, which cannot ask; those stay, and are handed to
+ /// the tab in front.
+ pub(crate) fn editor_close_tab_files(&mut self, tab_ix: usize, cx: &mut Context) {
+ let Some(ids) = self
+ .tabs
+ .get(tab_ix)
+ .and_then(|t| t.code.as_deref())
+ .map(|c| c.files.clone())
+ else {
+ return;
+ };
+ for id in ids {
+ if self.buffer_refs(id) == 1 && self.buffer(id).is_some_and(|b| !b.dirty) {
+ self.editor_drop_buffer(id, cx);
+ }
+ }
+ }
+
+ /// A tab was closed for good: forget what it had open.
+ pub(crate) fn editor_forget_tab(&mut self, tab: TabId, cx: &mut Context) {
+ self.editor.recorded.remove(&tab);
+ editor_session::remove(cx, tab);
+ }
+}
+
+fn dirs_home() -> PathBuf {
+ std::env::var_os("HOME")
+ .or_else(|| std::env::var_os("USERPROFILE"))
+ .map(PathBuf::from)
+ .unwrap_or_else(|| PathBuf::from("/"))
}
impl Tty7App {
@@ -1234,7 +2512,7 @@ impl Tty7App {
if !self.code_panel_visible() {
return None;
}
- let body = match self.tab_code().and_then(|c| c.active_file()) {
+ let body = match self.active_buffer() {
None => self.render_editor_empty(cx).into_any_element(),
Some(f) if f.preview => {
let markdown = f.input.read(cx).text().to_string();
@@ -1277,10 +2555,10 @@ impl Tty7App {
}
};
let conflict_banner = self
- .tab_code()
- .and_then(|c| c.active_file())
- .filter(|f| f.conflict)
- .map(|_| self.render_editor_conflict_banner(cx));
+ .active_buffer()
+ .and_then(|f| f.conflict.map(|c| (f.id(), c)))
+ .map(|(id, c)| self.render_editor_conflict_banner(id, c, cx));
+ let bar = self.render_editor_bar(cx);
let header = chrome
.renders_own_header()
@@ -1291,6 +2569,7 @@ impl Tty7App {
.h_full()
.children(header)
.when_some(conflict_banner, |this, b| this.child(b))
+ .children(bar)
.child(div().flex_1().min_h_0().child(body));
// The panel's own paint is the same either way; only the box is not.
@@ -1318,9 +2597,17 @@ impl Tty7App {
Some(
shell
.on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, window, cx| {
- if ev.keystroke.key == "escape" {
- this.toggle_code_panel(window, cx);
+ if ev.keystroke.key != "escape" {
+ return;
}
+ // Escape in the go-to-line or Save As box dismisses the
+ // box, not the whole editor.
+ if this.editor.bar.is_some() {
+ this.editor_close_bar(window, cx);
+ cx.stop_propagation();
+ return;
+ }
+ this.toggle_code_panel(window, cx);
}))
.child(h_flex().flex_1().min_h_0().w_full().child(editor_col))
.child(self.render_code_status_bar(window, cx))
@@ -1345,9 +2632,6 @@ impl Tty7App {
window: &mut Window,
cx: &mut Context,
) -> impl IntoElement + use<> {
- let active = self.tab_code().and_then(|c| c.active_file());
- let name = active.map(|f| f.label());
- let dirty = active.is_some_and(|f| f.dirty);
// `TITLE_BAR_LEAD` is the room macOS's traffic lights need. Only a
// header that starts at the left edge of the window has them to clear,
// and a docked column never does.
@@ -1363,48 +2647,80 @@ impl Tty7App {
row
};
let menu_app = cx.entity().downgrade();
- // v4 chrome: the file name in body ink at medium — the one heading the
- // column has — a hairline in the divider tone under the bar, and the
- // rail's 26px close tile, so the header reads as part of the plane it
- // sits in rather than a toolbar bolted on top of it.
+ // v4 chrome: the file names in body ink — the one heading the column
+ // has — a hairline in the divider tone under the bar, and the rail's
+ // 26px close tile, so the header reads as part of the plane it sits in
+ // rather than a toolbar bolted on top of it.
let (tile, glyph) = (
crate::ui::tab_strip::RAIL_TILE,
crate::ui::tab_strip::RAIL_TILE_GLYPH,
);
+ let files: Vec<(usize, SharedString, String, bool, bool)> = self
+ .tab_code()
+ .map(|c| {
+ c.files
+ .iter()
+ .enumerate()
+ .filter_map(|(pos, id)| {
+ let f = self.buffer(*id)?;
+ let tip = if f.untitled.is_some() {
+ f.label().to_string()
+ } else {
+ f.path.display().to_string()
+ };
+ Some((pos, f.label(), tip, pos == c.active, f.dirty))
+ })
+ .collect()
+ })
+ .unwrap_or_default();
+ let strip = if files.is_empty() {
+ div()
+ .min_w_0()
+ .text_ellipsis()
+ .text_size(gpui::rems(crate::ui::right_panel::TEXT))
+ .text_color(cx.theme().muted_foreground)
+ .child(SharedString::from(t(L10nKey::EditorNoFileOpen)))
+ .into_any_element()
+ } else {
+ h_flex()
+ .id("editor-file-tabs")
+ .min_w_0()
+ .h_full()
+ .overflow_x_scroll()
+ .children(files.into_iter().map(|(pos, name, tip, active, dirty)| {
+ self.render_file_tab(pos, name, tip, active, dirty, cx)
+ }))
+ .into_any_element()
+ };
row.flex_none()
.h(px(crate::ui::app::TITLE_BAR_HEIGHT))
.items_center()
- .gap(px(8.))
- .pl(px(lead))
+ .gap(px(4.))
+ .pl(px(lead - 8.).max(px(0.)))
// The glyph, not the tile, lands on `CONTENT_INSET`, the column
// the file name starts on at the other end of the bar.
.pr(px(crate::ui::app::CONTENT_INSET - (tile - glyph) / 2.))
.border_b(crate::ui::theme::hairline(window))
.border_color(cx.theme().sidebar_border)
+ .child(strip)
.child(
- div()
- .flex_1()
- .min_w_0()
- .text_ellipsis()
- .text_size(gpui::rems(crate::ui::right_panel::TEXT))
- .map(|d| match name.is_some() {
- true => d.font_weight(gpui::FontWeight::MEDIUM),
- false => d.text_color(cx.theme().muted_foreground),
- })
- .child(
- name.unwrap_or_else(|| SharedString::from(t(L10nKey::EditorNoFileOpen))),
- ),
+ div().occlude().flex_shrink_0().child(
+ crate::ui::tab_strip::chrome_tile_sized(
+ Button::new("editor-new-file").icon(Icon::new(IconName::Plus)),
+ tile,
+ glyph,
+ false,
+ cx,
+ )
+ .rounded(px(crate::ui::tab_strip::RAIL_TILE_RADIUS))
+ .tooltip(t(L10nKey::EditorNewFile))
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.editor_new_file(window, cx);
+ })),
+ ),
)
- // Unsaved: the sidebar's 5px status dot, in the warning ink.
- .when(dirty, |d| {
- d.child(
- div()
- .flex_none()
- .size(px(crate::ui::tab_strip::ROW_STATUS_DOT))
- .rounded_full()
- .bg(cx.theme().warning),
- )
- })
+ // Whatever is left of the bar stays a place to drag the window by.
+ .child(div().flex_1().h_full())
.child(
div().occlude().flex_shrink_0().child(
crate::ui::tab_strip::chrome_tile_sized(
@@ -1426,6 +2742,146 @@ impl Tty7App {
})
}
+ /// One file in the header's strip: its name, and a slot that shows the
+ /// unsaved dot at rest and the close button under the pointer — the dot
+ /// says there is something to lose before the × offers to lose it.
+ fn render_file_tab(
+ &self,
+ pos: usize,
+ name: SharedString,
+ tip: String,
+ active: bool,
+ dirty: bool,
+ cx: &mut Context,
+ ) -> AnyElement {
+ let group: SharedString = format!("editor-file-tab-{pos}").into();
+ let slot = crate::ui::tab_strip::ROW_STATUS_SLOT;
+ let close = div()
+ .id(("editor-file-tab-close", pos))
+ .flex_none()
+ .size(px(slot))
+ .flex()
+ .items_center()
+ .justify_center()
+ .rounded(px(3.))
+ .hover(|s| s.bg(cx.theme().muted))
+ .child(
+ Icon::new(IconName::Close)
+ .xsmall()
+ .text_color(cx.theme().muted_foreground),
+ )
+ .on_click(cx.listener(move |this, _, window, cx| {
+ cx.stop_propagation();
+ this.editor_close_file(pos, window, cx);
+ }));
+ let slot_el = div()
+ .flex_none()
+ .size(px(slot))
+ .flex()
+ .items_center()
+ .justify_center()
+ .map(|d| {
+ if dirty {
+ d.child(
+ div()
+ .group_hover(group.clone(), |s| s.invisible())
+ .size(px(crate::ui::tab_strip::ROW_STATUS_DOT))
+ .rounded_full()
+ .bg(cx.theme().warning),
+ )
+ } else {
+ d
+ }
+ });
+ div()
+ .id(("editor-file-tab", pos))
+ .group(group.clone())
+ .occlude()
+ .flex_none()
+ .h(px(26.))
+ .flex()
+ .items_center()
+ .gap(px(4.))
+ .pl(px(8.))
+ .pr(px(4.))
+ .rounded(px(crate::ui::tab_strip::RAIL_TILE_RADIUS))
+ .text_size(gpui::rems(crate::ui::right_panel::TEXT))
+ .map(|d| match active {
+ true => d
+ .font_weight(gpui::FontWeight::MEDIUM)
+ .text_color(cx.theme().foreground)
+ .bg(cx.theme().sidebar_accent),
+ false => d.text_color(cx.theme().muted_foreground).hover(|s| {
+ s.bg(gpui::rgb(
+ cx.global::().sidebar.hover,
+ ))
+ }),
+ })
+ .child(div().whitespace_nowrap().child(name))
+ .child(
+ div()
+ .relative()
+ .flex_none()
+ .size(px(slot))
+ .child(div().absolute().inset_0().child(slot_el))
+ .child(
+ div()
+ .absolute()
+ .inset_0()
+ .when(!active, |d| d.invisible())
+ .when(dirty, |d| d.invisible())
+ .group_hover(group, |s| s.visible())
+ .child(close),
+ ),
+ )
+ .tooltip(move |window, cx| {
+ gpui_component::tooltip::Tooltip::new(tip.clone()).build(window, cx)
+ })
+ .on_click(cx.listener(move |this, _, window, cx| {
+ this.editor_activate(pos, window, cx);
+ }))
+ .on_mouse_down(
+ MouseButton::Middle,
+ cx.listener(move |this, _, window, cx| {
+ this.editor_close_file(pos, window, cx);
+ }),
+ )
+ .into_any_element()
+ }
+
+ fn render_editor_bar(&self, cx: &mut Context) -> Option {
+ let bar = self.editor.bar.as_ref()?;
+ let label = match bar.kind {
+ BarKind::GoToLine => t(L10nKey::EditorGoToLine),
+ BarKind::SaveAs { .. } => t(L10nKey::EditorSaveAs),
+ };
+ Some(
+ h_flex()
+ .flex_none()
+ .w_full()
+ .items_center()
+ .gap_2()
+ .px(px(crate::ui::app::CONTENT_INSET))
+ .py_1()
+ .border_b_1()
+ .border_color(cx.theme().sidebar_border)
+ .text_sm()
+ .child(
+ div()
+ .flex_none()
+ .text_color(cx.theme().muted_foreground)
+ .child(label),
+ )
+ .child(
+ div()
+ .flex_1()
+ .min_w_0()
+ .child(Input::new(&bar.input).small()),
+ )
+ .into_any_element(),
+ )
+ }
+
fn render_code_status_bar(&self, window: &Window, cx: &mut Context) -> gpui::Div {
// The roots below belong to this window's own machine. A file read
// over SFTP is on another one, where they mean nothing, so it shows
@@ -1433,6 +2889,7 @@ impl Tty7App {
let tree_host = self.spawn_host(cx);
let code = self.tab_code();
let muted = cx.theme().muted_foreground;
+ let active = self.active_buffer();
let path_text: Option = code.map(|c| {
let repo = c
.roots
@@ -1440,7 +2897,8 @@ impl Tty7App {
.and_then(|r| r.file_name())
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
- match c.active_file() {
+ match active {
+ Some(f) if f.untitled.is_some() => f.label(),
Some(f) if f.host.id() != tree_host => f.path.display().to_string().into(),
Some(f) => {
let rel = c
@@ -1454,7 +2912,6 @@ impl Tty7App {
None => repo.into(),
}
});
- let active = code.and_then(|c| c.active_file());
let cursor: Option = active.map(|f| {
let pos = f.input.read(cx).cursor_position();
t_fmt(
@@ -1467,8 +2924,18 @@ impl Tty7App {
.into()
});
let wrap: Option = active.map(|f| f.wrap);
- let is_markdown = active.is_some_and(|f| language_for_path(&f.path) == "markdown");
+ let is_markdown = active.is_some_and(|f| f.language() == "markdown");
let preview = active.is_some_and(|f| f.preview);
+ let indent: Option = active.map(|f| {
+ let key = if f.indent.hard_tabs {
+ L10nKey::EditorIndentTabs
+ } else {
+ L10nKey::EditorIndentSpaces
+ };
+ t_fmt(key, &[("n", &f.indent.size.to_string())]).into()
+ });
+ let line_ending: Option<&'static str> = active.map(|f| f.format.line_ending.label());
+ let encoding: Option = active.map(|f| f.format.encoding_label().into());
// Metadata, not a toolbar: caption ink on the plane's own fill, set
// off by a hairline in the divider tone rather than a control border.
@@ -1517,6 +2984,17 @@ impl Tty7App {
),
)
})
+ .when_some(indent, |this, t| this.child(div().flex_none().child(t)))
+ .when_some(encoding, |this, t| this.child(div().flex_none().child(t)))
+ .when_some(line_ending, |this, eol| {
+ this.child(
+ Button::new("status-eol")
+ .label(eol)
+ .custom(crate::ui::tab_strip::chrome_tile_variant(cx))
+ .xsmall()
+ .on_click(cx.listener(|this, _, _w, cx| this.toggle_line_ending(cx))),
+ )
+ })
// Tabular figures, so the position does not jitter sideways as
// the caret walks from line 9 to line 10.
.when_some(cursor, |this, t| {
@@ -1549,10 +3027,13 @@ impl Tty7App {
)
}
- fn render_editor_conflict_banner(&self, cx: &mut Context) -> AnyElement {
- let tab_ix = self.active;
- let ix = self.tab_code().map(|c| c.active).unwrap_or(0);
- h_flex()
+ fn render_editor_conflict_banner(
+ &self,
+ id: BufferId,
+ conflict: DiskConflict,
+ cx: &mut Context,
+ ) -> AnyElement {
+ let row = h_flex()
.flex_none()
.w_full()
.items_center()
@@ -1562,31 +3043,55 @@ impl Tty7App {
.bg(cx.theme().warning.opacity(0.15))
.border_b_1()
.border_color(cx.theme().border)
- .text_sm()
- .child(div().flex_1().child(crate::ui::i18n::t(
- crate::ui::i18n::L10nKey::FileChangedOnDisk,
- )))
- .child(
- Button::new("editor-conflict-reload")
- .label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::Reload))
- .small()
- .on_click(cx.listener(move |this, _, window, cx| {
- this.editor_reload_from_disk(tab_ix, ix, window, cx);
- })),
- )
- .child(
- Button::new("editor-conflict-keep")
- .label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::KeepMine))
- .ghost()
- .small()
- .on_click(cx.listener(move |this, _, _w, cx| {
- if let Some(f) = this.tab_code_mut().and_then(|c| c.files.get_mut(ix)) {
- f.conflict = false;
- cx.notify();
- }
- })),
- )
- .into_any_element()
+ .text_sm();
+ match conflict {
+ DiskConflict::Changed(observed) => row
+ .child(div().flex_1().child(t(L10nKey::FileChangedOnDisk)))
+ .child(
+ Button::new("editor-conflict-reload")
+ .label(t(L10nKey::Reload))
+ .small()
+ .on_click(cx.listener(move |this, _, window, cx| {
+ this.editor_reload_from_disk(id, window, cx);
+ })),
+ )
+ .child(
+ Button::new("editor-conflict-keep")
+ .label(t(L10nKey::KeepMine))
+ .ghost()
+ .small()
+ .on_click(cx.listener(move |this, _, _w, cx| {
+ if let Some(f) = this.buffer_mut(id) {
+ // The version seen on disk is now the one a
+ // save is allowed to replace.
+ f.disk_mtime = observed;
+ f.conflict = None;
+ cx.notify();
+ }
+ })),
+ )
+ .into_any_element(),
+ DiskConflict::Deleted => row
+ .child(div().flex_1().child(t(L10nKey::EditorFileDeletedOnDisk)))
+ .child(
+ Button::new("editor-deleted-save")
+ .label(t(L10nKey::Save))
+ .small()
+ .on_click(cx.listener(move |this, _, window, cx| {
+ this.editor_save_file(id, false, true, window, cx);
+ })),
+ )
+ .child(
+ Button::new("editor-deleted-close")
+ .label(t(L10nKey::Close))
+ .ghost()
+ .small()
+ .on_click(cx.listener(move |this, _, _w, cx| {
+ this.editor_drop_buffer(id, cx);
+ })),
+ )
+ .into_any_element(),
+ }
}
}
@@ -1639,84 +3144,159 @@ mod tests {
}
}
- #[test]
- fn binary_sniff_flags_nul_bytes_only() {
- assert!(looks_binary(b"\x7fELF\x00\x01"));
- assert!(!looks_binary("plain text\nwith lines".as_bytes()));
- assert!(!looks_binary("中文 UTF-8 内容".as_bytes()));
- }
-
- fn t(secs: i64, nanos: u32) -> Option {
+ fn mt(secs: i64, nanos: u32) -> Option {
Some(MTime { secs, nanos })
}
#[test]
fn external_changes_are_told_apart_from_our_own_saves() {
- let ours = t(100, 0);
+ let ours = mt(100, 0);
+ let seen = |m| Observed::Present(m);
assert_eq!(
- classify_external_change(false, false, ours, ours),
+ classify_external_change(false, false, ours, seen(ours)),
ExternalChange::Ignore
);
-
assert_eq!(
- classify_external_change(false, false, ours, t(101, 0)),
+ classify_external_change(false, false, ours, seen(mt(101, 0))),
ExternalChange::Reload
);
-
assert_eq!(
- classify_external_change(false, true, ours, t(101, 0)),
+ classify_external_change(false, true, ours, seen(mt(101, 0))),
ExternalChange::Conflict
);
-
assert_eq!(
- classify_external_change(false, false, t(100, 0), t(100, 1)),
+ classify_external_change(false, false, mt(100, 0), seen(mt(100, 1))),
ExternalChange::Reload
);
-
assert_eq!(
- classify_external_change(true, false, ours, t(101, 0)),
+ classify_external_change(true, false, ours, seen(mt(101, 0))),
ExternalChange::Ignore
);
-
assert_eq!(
- classify_external_change(false, false, None, None),
+ classify_external_change(false, false, None, seen(None)),
ExternalChange::Reload
);
}
#[test]
- fn a_landed_save_only_cleans_a_buffer_that_did_not_move() {
+ fn a_file_that_vanished_is_reported_deleted_whether_or_not_it_was_edited() {
assert_eq!(
- settle_save(true, 7, 7, false),
- SaveLanding {
- clean: true,
- requeue: false
- }
+ classify_external_change(false, false, mt(1, 0), Observed::Missing),
+ ExternalChange::Deleted
);
-
assert_eq!(
- settle_save(true, 7, 9, false),
- SaveLanding {
- clean: false,
- requeue: false
- }
+ classify_external_change(false, true, mt(1, 0), Observed::Missing),
+ ExternalChange::Deleted
);
-
+ // Our own save replaces the file by rename, and a watcher can catch
+ // the instant between.
assert_eq!(
- settle_save(true, 7, 9, true),
- SaveLanding {
- clean: false,
- requeue: true
- }
+ classify_external_change(true, false, mt(1, 0), Observed::Missing),
+ ExternalChange::Ignore
);
+ }
+ #[test]
+ fn the_fingerprint_does_not_depend_on_how_the_text_is_chunked() {
+ let text = "fn main() {\n println!(\"中文\");\n}\n";
+ let whole = Fingerprint::of_str(text);
+ let (a, b) = text.split_at(13);
assert_eq!(
- settle_save(false, 7, 7, true),
- SaveLanding {
- clean: false,
- requeue: false
- }
+ Fingerprint::of_chunks(text.len(), [a, b].into_iter()),
+ whole
+ );
+ assert_ne!(Fingerprint::of_str("fn main() {}\n"), whole);
+ }
+
+ #[test]
+ fn only_the_span_that_changed_is_replaced() {
+ assert_eq!(differing_span("abc", "abc"), None);
+ assert_eq!(
+ differing_span("hello world", "hello there world"),
+ Some((6, 6, 12))
+ );
+ assert_eq!(differing_span("abc", "abXc"), Some((2, 2, 3)));
+ assert_eq!(differing_span("abc", ""), Some((0, 3, 0)));
+ assert_eq!(differing_span("", "new"), Some((0, 0, 3)));
+ // "aaa" → "aa": the overlap of prefix and suffix must not double-count.
+ assert_eq!(differing_span("aaa", "aa"), Some((2, 3, 2)));
+ }
+
+ #[test]
+ fn a_changed_span_never_splits_a_character() {
+ // 中 and 丰 share their first two bytes in UTF-8.
+ let (s, oe, ne) = differing_span("x中y", "x丰y").unwrap();
+ let (old, new) = ("x中y", "x丰y");
+ assert!(old.is_char_boundary(s) && new.is_char_boundary(s));
+ assert_eq!(&old[s..oe], "中");
+ assert_eq!(&new[s..ne], "丰");
+ }
+
+ #[test]
+ fn go_to_line_reads_the_forms_people_type() {
+ assert_eq!(parse_line_target("120"), Some((120, 1)));
+ assert_eq!(parse_line_target(" 120:4 "), Some((120, 4)));
+ assert_eq!(parse_line_target(":7"), Some((7, 1)));
+ assert_eq!(parse_line_target("7,3"), Some((7, 3)));
+ assert_eq!(parse_line_target("7:"), Some((7, 1)));
+ assert_eq!(parse_line_target("0"), None);
+ assert_eq!(parse_line_target("abc"), None);
+ assert_eq!(parse_line_target(""), None);
+ }
+
+ #[test]
+ fn a_strip_opens_new_files_beside_the_current_one_and_closes_toward_the_right() {
+ let ids: Vec = (1..=4u64).map(gpui::EntityId::from).collect();
+ let mut code = TabCode::new();
+ code.show(ids[0]);
+ code.show(ids[1]);
+ assert_eq!(code.files, vec![ids[0], ids[1]]);
+ code.active = 0;
+ code.show(ids[2]);
+ assert_eq!(
+ code.files,
+ vec![ids[0], ids[2], ids[1]],
+ "opened right of the active one"
+ );
+ assert_eq!(code.active, 1);
+
+ // Showing one already open only brings it forward.
+ code.show(ids[1]);
+ assert_eq!(code.files.len(), 3);
+ assert_eq!(code.active, 2);
+
+ // Closing the active file brings its right-hand neighbour forward,
+ // or the left one at the end of the strip.
+ code.active = 1;
+ assert!(code.forget(ids[2]));
+ assert_eq!(code.files, vec![ids[0], ids[1]]);
+ assert_eq!(code.active, 1);
+ assert!(code.forget(ids[1]));
+ assert_eq!(code.active, 0);
+ // Closing one left of the active file keeps the same file in front.
+ code.show(ids[3]);
+ assert_eq!(code.files, vec![ids[0], ids[3]]);
+ assert!(code.forget(ids[0]));
+ assert_eq!(code.active_id(), Some(ids[3]));
+ assert!(!code.forget(ids[0]));
+ }
+
+ #[test]
+ fn files_arriving_in_the_background_keep_their_order_and_the_front_file() {
+ let ids: Vec = (1..=4u64).map(gpui::EntityId::from).collect();
+ let mut code = TabCode::new();
+ code.show(ids[0]);
+ // A restore lists files in the order they were recorded; showing each
+ // beside the active one would have reversed them.
+ code.adopt(&ids[1..]);
+ assert_eq!(code.files, ids);
+ assert_eq!(code.active_id(), Some(ids[0]));
+ code.adopt(&ids[2..3]);
+ assert_eq!(
+ code.files.len(),
+ 4,
+ "a file already listed is not listed twice"
);
}
}
diff --git a/src/ui/editor_session.rs b/src/ui/editor_session.rs
new file mode 100644
index 00000000..4b198fb5
--- /dev/null
+++ b/src/ui/editor_session.rs
@@ -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,
+ #[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,
+}
+
+#[derive(Default)]
+struct EditorSessionStore {
+ doc: Doc,
+ write_scheduled: bool,
+}
+
+impl Global for EditorSessionStore {}
+
+fn store(cx: &mut App) -> &mut EditorSessionStore {
+ if cx.try_global::().is_none() {
+ cx.set_global(EditorSessionStore {
+ doc: load(),
+ write_scheduled: false,
+ });
+ }
+ cx.global_mut::()
+}
+
+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 {
+ 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::();
+ 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()));
+ }
+}
diff --git a/src/ui/editor_text.rs b/src/ui/editor_text.rs
new file mode 100644
index 00000000..057bae72
--- /dev/null
+++ b/src/ui/editor_text.rs
@@ -0,0 +1,1319 @@
+//! The code editor's view of a file's bytes: how they decode into editable
+//! text, how that text is written back, how the file is indented, and what an
+//! `.editorconfig` says about it.
+//!
+//! Everything here is pure — no I/O, no gpui — so the editor can read files
+//! through whatever host abstraction it uses (local disk, SFTP, a remote
+//! `tty7-server`) and hand the bytes over. The one promise the module makes is
+//! that a file the editor opens and saves unchanged comes back byte-for-byte:
+//! the encoding, the byte-order mark and the line endings are remembered in a
+//! [`TextFormat`] on the way in and restored by [`encode`] on the way out.
+
+use std::collections::HashMap;
+use std::fmt;
+use std::path::{Component, Path, PathBuf};
+
+use encoding_rs::{EncoderResult, Encoding, GB18030, UTF_8, UTF_16BE, UTF_16LE, WINDOWS_1252};
+
+// ---------------------------------------------------------------------------
+// Decoding and encoding
+// ---------------------------------------------------------------------------
+
+/// How much of the file is searched for a NUL byte before deciding it is
+/// binary. The same window git uses: large enough that a text file's header
+/// is covered, small enough that a multi-gigabyte blob costs nothing.
+const BINARY_SNIFF_LEN: usize = 8 * 1024;
+
+const UTF8_BOM: &[u8] = &[0xEF, 0xBB, 0xBF];
+const UTF16LE_BOM: &[u8] = &[0xFF, 0xFE];
+const UTF16BE_BOM: &[u8] = &[0xFE, 0xFF];
+
+/// The line break a file uses. A lone `\r` (classic Mac OS) has no variant:
+/// such files are rare enough that they are left exactly as they are rather
+/// than converted, which keeps them round-tripping untouched.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub(crate) enum LineEnding {
+ #[default]
+ Lf,
+ CrLf,
+}
+
+impl LineEnding {
+ /// The status-bar label.
+ pub(crate) fn label(self) -> &'static str {
+ match self {
+ LineEnding::Lf => "LF",
+ LineEnding::CrLf => "CRLF",
+ }
+ }
+}
+
+/// Everything about a file's bytes that the editable text does not carry, so
+/// that [`encode`] can put it back.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) struct TextFormat {
+ pub(crate) encoding: &'static Encoding,
+ /// Whether the file started with a byte-order mark. Always true for UTF-16,
+ /// which is only ever recognised by its BOM.
+ pub(crate) bom: bool,
+ pub(crate) line_ending: LineEnding,
+}
+
+impl Default for TextFormat {
+ /// What a brand-new file is written as: plain UTF-8, no BOM, LF.
+ fn default() -> Self {
+ TextFormat {
+ encoding: UTF_8,
+ bom: false,
+ line_ending: LineEnding::Lf,
+ }
+ }
+}
+
+impl TextFormat {
+ /// The status-bar label, e.g. "UTF-8", "UTF-8 BOM", "UTF-16 LE", "GB18030".
+ pub(crate) fn encoding_label(&self) -> &'static str {
+ if self.encoding == UTF_8 && self.bom {
+ "UTF-8 BOM"
+ } else {
+ encoding_name(self.encoding)
+ }
+ }
+}
+
+/// A human name for an encoding. encoding_rs's own names are the WHATWG
+/// canonical ones ("UTF-16LE", "windows-1252"), which read like identifiers in
+/// a status bar, so the handful the editor can produce get friendlier ones.
+fn encoding_name(encoding: &'static Encoding) -> &'static str {
+ if encoding == UTF_8 {
+ "UTF-8"
+ } else if encoding == UTF_16LE {
+ "UTF-16 LE"
+ } else if encoding == UTF_16BE {
+ "UTF-16 BE"
+ } else if encoding == GB18030 {
+ "GB18030"
+ } else if encoding == WINDOWS_1252 {
+ "Windows-1252"
+ } else {
+ encoding.name()
+ }
+}
+
+/// A file's bytes as editable text, plus what is needed to write it back.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) struct Decoded {
+ /// The text with any BOM removed and, when the file is CRLF, every `\r\n`
+ /// turned into `\n`.
+ pub(crate) text: String,
+ pub(crate) format: TextFormat,
+}
+
+/// Decodes a file for editing, or returns `None` when it looks binary.
+///
+/// The order of the checks matters:
+///
+/// 1. A byte-order mark is the file stating its encoding, so it wins outright:
+/// UTF-8, UTF-16 LE or UTF-16 BE. UTF-16 is full of NUL bytes, which is why
+/// this runs before the binary check. (A UTF-32 LE BOM starts with the
+/// UTF-16 LE one and is read as UTF-16; UTF-32 text files are too rare to
+/// carry a separate path.)
+/// 2. A NUL byte in the first 8 KiB means binary. Text in every encoding this
+/// function can otherwise produce never contains one.
+/// 3. Valid UTF-8 is UTF-8. Plain ASCII lands here too.
+/// 4. Anything else is legacy-encoded text; see [`decode_legacy`].
+///
+/// A BOM-less file that is neither UTF-8 nor binary is never refused: the
+/// Windows-1252 fallback maps every byte, so the worst case is mojibake that
+/// still saves back to the original bytes.
+pub(crate) fn decode(bytes: &[u8]) -> Option {
+ let (text, encoding, bom) = if let Some(rest) = bytes.strip_prefix(UTF8_BOM) {
+ // A file that declares UTF-8 and then breaks it is damaged rather than
+ // secretly in another encoding, so it is shown with replacement
+ // characters instead of being second-guessed.
+ (String::from_utf8_lossy(rest).into_owned(), UTF_8, true)
+ } else if let Some(rest) = bytes.strip_prefix(UTF16LE_BOM) {
+ (decode_utf16(UTF_16LE, rest), UTF_16LE, true)
+ } else if let Some(rest) = bytes.strip_prefix(UTF16BE_BOM) {
+ (decode_utf16(UTF_16BE, rest), UTF_16BE, true)
+ } else if bytes[..bytes.len().min(BINARY_SNIFF_LEN)].contains(&0) {
+ return None;
+ } else if let Ok(text) = std::str::from_utf8(bytes) {
+ (text.to_owned(), UTF_8, false)
+ } else {
+ let (text, encoding) = decode_legacy(bytes);
+ (text, encoding, false)
+ };
+
+ let line_ending = detect_line_ending(&text);
+ let text = match line_ending {
+ LineEnding::CrLf => text.replace("\r\n", "\n"),
+ LineEnding::Lf => text,
+ };
+ Some(Decoded {
+ text,
+ format: TextFormat {
+ encoding,
+ bom,
+ line_ending,
+ },
+ })
+}
+
+/// Decodes the body of a UTF-16 file. An odd trailing byte or an unpaired
+/// surrogate is replaced rather than refused: the BOM already told us this is
+/// UTF-16 text, and showing it damaged beats not showing it. Such a file will
+/// not save back byte-for-byte, which is the price of it being malformed.
+fn decode_utf16(encoding: &'static Encoding, body: &[u8]) -> String {
+ let (text, _had_errors) = encoding.decode_without_bom_handling(body);
+ text.into_owned()
+}
+
+/// Decodes text that is neither UTF-8 nor marked with a BOM.
+///
+/// Only two candidates are tried, GB18030 and Windows-1252, and the reasoning
+/// is about which mistakes are cheap:
+///
+/// - GB18030 (the superset of GBK and GB2312) is accepted only when all of
+/// these hold: the bytes decode with no malformed sequences at all; at least
+/// 80% of the multi-byte characters sit in the GB2312 zone, where both bytes
+/// are `0xA1..=0xFE` (with a lead of at most `0xF7`) — the zone real
+/// simplified-Chinese text lives in, covering the common hanzi and full-width
+/// punctuation; and re-encoding the decoded text reproduces the input
+/// exactly. Latin text in a single-byte code page fails the first test
+/// almost immediately, because an accented letter followed by a space, a
+/// digit or punctuation is not a valid GB18030 pair. When an accented letter
+/// is followed by an ASCII letter the pair *is* valid GBK, but its trail
+/// byte is below `0xA1`, so the zone test rejects it. The round-trip test
+/// guarantees that choosing GB18030 never costs a byte on save.
+/// - Windows-1252 is the fallback because it can decode every byte and encode
+/// every result back, so a misjudged file is displayed wrongly but never
+/// corrupted by an unchanged save.
+///
+/// Shift_JIS, EUC-KR and Big5 are deliberately not guessed. EUC-KR occupies
+/// the same byte zone as GB2312, and Shift_JIS/Big5 overlap GBK's pair ranges,
+/// so telling them apart needs character-frequency models, not a range check.
+/// A heuristic that is wrong half the time on those files is worse than a
+/// predictable fallback: they open as Windows-1252 (or, for EUC-KR, as
+/// GB18030 mojibake) and still save back unchanged.
+fn decode_legacy(bytes: &[u8]) -> (String, &'static Encoding) {
+ if let Some(text) = GB18030.decode_without_bom_handling_and_without_replacement(bytes)
+ && looks_like_gb2312_text(bytes)
+ && encode_legacy(&text, GB18030).is_ok_and(|encoded| encoded == bytes)
+ {
+ return (text.into_owned(), GB18030);
+ }
+ let (text, _had_errors) = WINDOWS_1252.decode_without_bom_handling(bytes);
+ (text.into_owned(), WINDOWS_1252)
+}
+
+/// The GB2312-zone test described on [`decode_legacy`]. The input must
+/// already be known to be well-formed GB18030, which is what makes it safe to
+/// walk it by lead byte alone.
+fn looks_like_gb2312_text(bytes: &[u8]) -> bool {
+ let mut in_zone = 0usize;
+ let mut other = 0usize;
+ let mut i = 0;
+ while i < bytes.len() {
+ let lead = bytes[i];
+ if lead < 0x80 {
+ i += 1;
+ continue;
+ }
+ match bytes.get(i + 1) {
+ // `0x80` is a one-byte euro sign in encoding_rs's decoder, and
+ // neither it nor `0xFF` can lead a pair.
+ _ if lead == 0x80 || lead == 0xFF => {
+ other += 1;
+ i += 1;
+ }
+ // A four-byte sequence: GB18030's mapping of the rest of Unicode.
+ Some(0x30..=0x39) => {
+ other += 1;
+ i += 4;
+ }
+ Some(&trail) => {
+ if (0xA1..=0xF7).contains(&lead) && (0xA1..=0xFE).contains(&trail) {
+ in_zone += 1;
+ } else {
+ other += 1;
+ }
+ i += 2;
+ }
+ None => {
+ other += 1;
+ i += 1;
+ }
+ }
+ }
+ in_zone > 0 && in_zone * 10 >= (in_zone + other) * 8
+}
+
+/// CRLF when `\r\n` breaks outnumber lone `\n` breaks; LF on a tie or when the
+/// text has no breaks at all, since LF is what a new line should get by
+/// default. A file with mixed endings is normalised to its majority, so an
+/// unchanged save of such a file makes it consistent — the one case where
+/// bytes change without an edit, and arguably the right one.
+fn detect_line_ending(text: &str) -> LineEnding {
+ let bytes = text.as_bytes();
+ let mut crlf = 0usize;
+ let mut lf = 0usize;
+ for (i, _) in bytes.iter().enumerate().filter(|(_, b)| **b == b'\n') {
+ if i > 0 && bytes[i - 1] == b'\r' {
+ crlf += 1;
+ } else {
+ lf += 1;
+ }
+ }
+ if crlf > lf {
+ LineEnding::CrLf
+ } else {
+ LineEnding::Lf
+ }
+}
+
+/// Why text could not be written in its file's encoding.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) struct EncodeError {
+ /// The human name of the encoding, as [`TextFormat::encoding_label`]
+ /// spells it without the BOM suffix.
+ pub(crate) encoding: &'static str,
+ /// The first character the encoding has no bytes for.
+ pub(crate) unmappable: char,
+}
+
+impl fmt::Display for EncodeError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(
+ f,
+ "can't save as {}: contains '{}'",
+ self.encoding, self.unmappable
+ )
+ }
+}
+
+impl std::error::Error for EncodeError {}
+
+/// Writes editor text back in the file's original format: `\n` becomes `\r\n`
+/// for a CRLF file, the BOM is restored, and the text is encoded in the
+/// original encoding.
+///
+/// The CRLF expansion is a plain replacement. A CRLF file's text never holds a
+/// `\r\n` after [`decode`], so this is exact for anything decode produced; a
+/// `\r` the user typed before a newline would be written as `\r\r\n`, which is
+/// also what the file would have to contain to decode to that text.
+///
+/// Fails, naming the first offending character, when the text holds something
+/// the encoding cannot represent — an emoji typed into a Windows-1252 file,
+/// say. Nothing is silently replaced, so the caller can tell the user and let
+/// them choose another encoding instead.
+pub(crate) fn encode(text: &str, format: &TextFormat) -> Result, EncodeError> {
+ let expanded;
+ let text = match format.line_ending {
+ LineEnding::CrLf => {
+ expanded = text.replace('\n', "\r\n");
+ expanded.as_str()
+ }
+ LineEnding::Lf => text,
+ };
+
+ let encoding = format.encoding;
+ // encoding_rs has no UTF-16 encoder (the WHATWG standard it implements only
+ // ever emits UTF-8 for UTF-16 pages), so UTF-16 is written by hand. Every
+ // `char` is representable, so neither UTF-16 nor UTF-8 can fail.
+ if encoding == UTF_16LE || encoding == UTF_16BE {
+ let little = encoding == UTF_16LE;
+ let mut out = Vec::with_capacity(2 + text.len() * 2);
+ if format.bom {
+ out.extend_from_slice(if little { UTF16LE_BOM } else { UTF16BE_BOM });
+ }
+ for unit in text.encode_utf16() {
+ out.extend_from_slice(&if little {
+ unit.to_le_bytes()
+ } else {
+ unit.to_be_bytes()
+ });
+ }
+ return Ok(out);
+ }
+ if encoding == UTF_8 {
+ let mut out = Vec::with_capacity(3 + text.len());
+ if format.bom {
+ out.extend_from_slice(UTF8_BOM);
+ }
+ out.extend_from_slice(text.as_bytes());
+ return Ok(out);
+ }
+ encode_legacy(text, encoding).map_err(|unmappable| EncodeError {
+ encoding: encoding_name(encoding),
+ unmappable,
+ })
+}
+
+/// Encodes with encoding_rs, refusing rather than substituting: encoding_rs's
+/// convenience `encode` would write an unmappable character as an HTML numeric
+/// reference like `😀`, which is right for a web form and silently
+/// corrupts a source file.
+fn encode_legacy(text: &str, encoding: &'static Encoding) -> Result, char> {
+ let mut encoder = encoding.new_encoder();
+ let mut out = Vec::with_capacity(text.len());
+ let mut buf = [0u8; 8 * 1024];
+ let mut rest = text;
+ loop {
+ let (result, read, written) =
+ encoder.encode_from_utf8_without_replacement(rest, &mut buf, true);
+ out.extend_from_slice(&buf[..written]);
+ rest = &rest[read..];
+ match result {
+ EncoderResult::InputEmpty => return Ok(out),
+ EncoderResult::OutputFull => {}
+ EncoderResult::Unmappable(c) => return Err(c),
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Indentation
+// ---------------------------------------------------------------------------
+
+/// How many lines [`detect_indent`] looks at. The top of a file is as
+/// representative as the rest of it, and a bound keeps opening a huge
+/// generated file instant.
+const INDENT_SCAN_LINES: usize = 1000;
+
+/// The display width of a hard tab when nothing says otherwise.
+const DEFAULT_TAB_WIDTH: usize = 4;
+
+/// How the editor indents: with hard tabs of display width `size`, or with
+/// `size` spaces.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) struct Indent {
+ pub(crate) hard_tabs: bool,
+ pub(crate) size: usize,
+}
+
+/// The indentation to use when a file offers no evidence — a new or flat file.
+/// Kept conservative: only languages whose tooling *requires* a style get one.
+/// Makefiles need tab-led recipes and gofmt writes tabs; YAML is written in
+/// two spaces nearly universally. Everything else gets four spaces, the most
+/// common single choice, rather than a per-language table of house styles
+/// that would be wrong for many projects anyway — those projects say what they
+/// want through `.editorconfig` or the file's existing lines.
+fn default_indent(language: &str) -> Indent {
+ match language {
+ "make" | "go" => Indent {
+ hard_tabs: true,
+ size: DEFAULT_TAB_WIDTH,
+ },
+ "yaml" => Indent {
+ hard_tabs: false,
+ size: 2,
+ },
+ _ => Indent {
+ hard_tabs: false,
+ size: 4,
+ },
+ }
+}
+
+/// Infers a file's indentation from its first lines, falling back to
+/// [`default_indent`] for `language` (a name from `language_for_path`).
+///
+/// Tabs versus spaces is a vote of lines by their first character. Lines led
+/// by exactly one space do not vote: they are nearly always the ` * ` of a
+/// block comment, which a tab-indented C file has at column zero.
+///
+/// The width of space indentation is the classic heuristic: every time a line
+/// is indented deeper than the previous non-blank line, the increase is
+/// tallied, and the most common of 2, 4 and 8 wins (ties prefer 4, then 2).
+/// Only increases count, because a dedent often closes several levels at
+/// once and would vote for a multiple of the real width. Other widths — the
+/// odd steps of aligned continuation lines, a comment's single space — carry
+/// no vote at all.
+pub(crate) fn detect_indent(text: &str, language: &str) -> Indent {
+ let default = default_indent(language);
+ let mut tab_lines = 0usize;
+ let mut space_lines = 0usize;
+ // Tallies of indent increases of 2, 4 and 8 spaces.
+ let mut steps = [0usize; 3];
+ let mut previous = 0usize;
+
+ for line in text.lines().take(INDENT_SCAN_LINES) {
+ let content = line.trim_start_matches([' ', '\t']);
+ // Blank lines say nothing, and must not reset `previous`: a block that
+ // resumes after an empty line has not been re-indented from zero.
+ if content.is_empty() {
+ continue;
+ }
+ if line.starts_with('\t') {
+ tab_lines += 1;
+ continue;
+ }
+ let spaces = line.len() - line.trim_start_matches(' ').len();
+ if spaces >= 2 {
+ space_lines += 1;
+ }
+ if spaces > previous {
+ match spaces - previous {
+ 2 => steps[0] += 1,
+ 4 => steps[1] += 1,
+ 8 => steps[2] += 1,
+ _ => {}
+ }
+ }
+ previous = spaces;
+ }
+
+ if tab_lines == 0 && space_lines == 0 {
+ return default;
+ }
+ let hard_tabs = match tab_lines.cmp(&space_lines) {
+ std::cmp::Ordering::Greater => true,
+ std::cmp::Ordering::Less => false,
+ std::cmp::Ordering::Equal => default.hard_tabs,
+ };
+ if hard_tabs {
+ return Indent {
+ hard_tabs: true,
+ size: DEFAULT_TAB_WIDTH,
+ };
+ }
+ // In preference order, so `max_by_key` (which keeps the last maximum)
+ // walks it reversed and the earliest preferred width wins a tie.
+ let size = [(4, steps[1]), (2, steps[0]), (8, steps[2])]
+ .into_iter()
+ .rev()
+ .max_by_key(|&(_, count)| count)
+ .filter(|&(_, count)| count > 0)
+ .map(|(size, _)| size)
+ .unwrap_or(if default.hard_tabs { 4 } else { default.size });
+ Indent {
+ hard_tabs: false,
+ size,
+ }
+}
+
+// ---------------------------------------------------------------------------
+// EditorConfig
+// ---------------------------------------------------------------------------
+
+/// The file name the caller looks for in each directory from the file's own
+/// upward.
+pub(crate) const EDITORCONFIG: &str = ".editorconfig";
+
+/// The properties of the editor acts on, as
+/// they apply to one file. `None` means no config said anything (or said
+/// `unset`), so the editor's own detection stands.
+///
+/// `charset` is not read: the editor keeps the encoding a file already has,
+/// and new files are UTF-8, so the property would only matter for converting
+/// files, which the editor does not do on its own.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub(crate) struct EditorConfig {
+ /// `indent_style`: `Some(true)` for `tab`, `Some(false)` for `space`.
+ pub(crate) hard_tabs: Option,
+ /// `indent_size`, with `tab` already resolved to the tab width.
+ pub(crate) indent_size: Option,
+ /// `tab_width`, defaulting to a numeric `indent_size` as the spec says.
+ pub(crate) tab_width: Option,
+ /// `end_of_line`. `cr` is ignored: the editor cannot write lone-CR files.
+ pub(crate) end_of_line: Option,
+ pub(crate) insert_final_newline: Option,
+ pub(crate) trim_trailing_whitespace: Option,
+}
+
+impl EditorConfig {
+ /// The indentation to edit with: whatever the config sets, and the
+ /// detected indentation for whatever it leaves open. For hard tabs the size
+ /// is the tab's display width, so `tab_width` is preferred there.
+ pub(crate) fn indent(&self, detected: Indent) -> Indent {
+ let hard_tabs = self.hard_tabs.unwrap_or(detected.hard_tabs);
+ let size = if hard_tabs {
+ self.tab_width.or(self.indent_size)
+ } else {
+ self.indent_size
+ };
+ Indent {
+ hard_tabs,
+ size: size.unwrap_or(detected.size),
+ }
+ }
+}
+
+/// Whether an `.editorconfig` declares `root = true` in its preamble, which
+/// tells the caller to stop walking up the directory tree.
+pub(crate) fn is_root(contents: &str) -> bool {
+ parse_editorconfig(contents).root
+}
+
+/// Resolves the properties that apply to `file`.
+///
+/// `configs` pairs each `.editorconfig`'s directory with its contents, ordered
+/// from the file's own directory upward. Anything past the first root config
+/// is ignored even if the caller passed it. Following the spec, farther
+/// configs are applied first so nearer ones override them, and within one file
+/// later sections override earlier ones.
+///
+/// Section globs are matched with globset (with `*` and `?` not crossing `/`),
+/// which covers `*`, `**`, `?`, `[abc]`, `[!abc]` and `{a,b}`. A glob without
+/// a `/` matches the file's name at any depth below the config's directory; a
+/// glob with one is anchored to that directory. Numeric ranges `{1..3}` are
+/// expanded into alternatives when they are short and not nested inside
+/// another brace group. Globs globset rejects — nested braces are the usual
+/// case — match nothing.
+pub(crate) fn editorconfig_for(file: &Path, configs: &[(PathBuf, String)]) -> EditorConfig {
+ let parsed: Vec<(&Path, ParsedConfig)> = {
+ let mut parsed = Vec::new();
+ for (dir, contents) in configs {
+ let config = parse_editorconfig(contents);
+ let root = config.root;
+ parsed.push((dir.as_path(), config));
+ if root {
+ break;
+ }
+ }
+ parsed
+ };
+
+ let mut properties: HashMap = HashMap::new();
+ for (dir, config) in parsed.iter().rev() {
+ let Some(relative) = relative_glob_path(file, dir) else {
+ continue;
+ };
+ for section in &config.sections {
+ if section_matches(§ion.glob, &relative) {
+ for (key, value) in §ion.properties {
+ properties.insert(key.clone(), value.clone());
+ }
+ }
+ }
+ }
+ resolve_properties(&properties)
+}
+
+struct ParsedConfig {
+ root: bool,
+ sections: Vec,
+}
+
+struct Section {
+ glob: String,
+ /// Keys lowercased; values lowercased too, since every value the editor
+ /// reads is case-insensitive.
+ properties: Vec<(String, String)>,
+}
+
+/// A forgiving INI reader: comment lines start with `#` or `;`, a `#` later
+/// in a line is part of the value (as the current spec requires), and a line
+/// that is neither a section header nor `key = value` is skipped.
+fn parse_editorconfig(contents: &str) -> ParsedConfig {
+ let mut root = false;
+ let mut sections: Vec = Vec::new();
+ let contents = contents.strip_prefix('\u{feff}').unwrap_or(contents);
+ for line in contents.lines() {
+ let line = line.trim();
+ if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
+ continue;
+ }
+ if let Some(glob) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
+ sections.push(Section {
+ glob: glob.to_owned(),
+ properties: Vec::new(),
+ });
+ continue;
+ }
+ let Some((key, value)) = line.split_once('=') else {
+ continue;
+ };
+ let key = key.trim().to_ascii_lowercase();
+ let value = value.trim().to_ascii_lowercase();
+ match sections.last_mut() {
+ Some(section) => section.properties.push((key, value)),
+ None if key == "root" => root = value == "true",
+ None => {}
+ }
+ }
+ ParsedConfig { root, sections }
+}
+
+/// `file` relative to `dir`, `/`-separated whatever the platform, or `None`
+/// when the file is not below that directory.
+fn relative_glob_path(file: &Path, dir: &Path) -> Option {
+ let relative = file.strip_prefix(dir).ok()?;
+ let mut parts = Vec::new();
+ for component in relative.components() {
+ match component {
+ Component::Normal(part) => parts.push(part.to_str()?),
+ _ => return None,
+ }
+ }
+ (!parts.is_empty()).then(|| parts.join("/"))
+}
+
+fn section_matches(glob: &str, relative: &str) -> bool {
+ let glob = expand_numeric_ranges(glob);
+ let pattern = if glob.contains('/') {
+ glob.strip_prefix('/').unwrap_or(&glob).to_owned()
+ } else {
+ format!("**/{glob}")
+ };
+ globset::GlobBuilder::new(&pattern)
+ .literal_separator(true)
+ // Explicit because globset's default differs by platform, and
+ // editorconfig globs use `\` as an escape everywhere.
+ .backslash_escape(true)
+ .build()
+ .is_ok_and(|glob| glob.compile_matcher().is_match(relative))
+}
+
+/// The most alternatives a `{m..n}` range is expanded into. Larger ranges are
+/// left alone (and so match nothing literal), which is fine for the small
+/// ranges configs actually use.
+const MAX_RANGE_EXPANSION: i64 = 1000;
+
+/// Rewrites `{m..n}` as `{m,m+1,…,n}` so globset can match it. This matches the
+/// spec for the integers written in their plain form, which is how file names
+/// that such ranges target are spelled.
+fn expand_numeric_ranges(glob: &str) -> String {
+ let mut out = String::with_capacity(glob.len());
+ let mut rest = glob;
+ while let Some(open) = rest.find('{') {
+ out.push_str(&rest[..open]);
+ let after = &rest[open + 1..];
+ let expanded = after.find('}').and_then(|close| {
+ let (lo, hi) = after[..close].split_once("..")?;
+ let (lo, hi): (i64, i64) = (lo.parse().ok()?, hi.parse().ok()?);
+ let (lo, hi) = (lo.min(hi), lo.max(hi));
+ (hi - lo < MAX_RANGE_EXPANSION).then(|| {
+ let numbers: Vec = (lo..=hi).map(|n| n.to_string()).collect();
+ (format!("{{{}}}", numbers.join(",")), close)
+ })
+ });
+ match expanded {
+ Some((replacement, close)) => {
+ out.push_str(&replacement);
+ rest = &after[close + 1..];
+ }
+ None => {
+ out.push('{');
+ rest = after;
+ }
+ }
+ }
+ out.push_str(rest);
+ out
+}
+
+fn resolve_properties(properties: &HashMap) -> EditorConfig {
+ // `unset` is stored like any other value so that it overrides an earlier
+ // setting, and only here does it turn back into "not set".
+ let get = |key: &str| {
+ properties
+ .get(key)
+ .map(String::as_str)
+ .filter(|value| *value != "unset")
+ };
+ let positive = |value: &str| value.parse::().ok().filter(|n| *n > 0);
+ let boolean = |key: &str| match get(key) {
+ Some("true") => Some(true),
+ Some("false") => Some(false),
+ _ => None,
+ };
+
+ let hard_tabs = match get("indent_style") {
+ Some("tab") => Some(true),
+ Some("space") => Some(false),
+ _ => None,
+ };
+ let numeric_indent = get("indent_size").and_then(positive);
+ let tab_width = get("tab_width").and_then(positive).or(numeric_indent);
+ let indent_size = match get("indent_size") {
+ Some("tab") => tab_width,
+ Some(_) => numeric_indent,
+ // The spec: with tab indentation and no size, the size is the tab.
+ None if hard_tabs == Some(true) => tab_width,
+ None => None,
+ };
+ let end_of_line = match get("end_of_line") {
+ Some("lf") => Some(LineEnding::Lf),
+ Some("crlf") => Some(LineEnding::CrLf),
+ _ => None,
+ };
+
+ EditorConfig {
+ hard_tabs,
+ indent_size,
+ tab_width,
+ end_of_line,
+ insert_final_newline: boolean("insert_final_newline"),
+ trim_trailing_whitespace: boolean("trim_trailing_whitespace"),
+ }
+}
+
+/// Applies the config's save-time rewrites to editor text, returning the new
+/// text, or `None` when nothing changes (so the caller can skip touching the
+/// buffer and its undo history).
+///
+/// Trailing whitespace means spaces and tabs only: stripping other Unicode
+/// spaces (a no-break space, say) could remove something deliberate. A `\r`
+/// at a line's end is kept, since it is a line break the editor preserves in
+/// an LF file, not whitespace to trim.
+///
+/// `insert_final_newline = true` adds a `\n` to non-empty text that lacks
+/// one; an empty file stays empty. `false` never removes an existing one —
+/// the spec leaves that to taste and removing it would surprise more people.
+pub(crate) fn apply_save_rules(text: &str, cfg: &EditorConfig) -> Option {
+ let mut out = if cfg.trim_trailing_whitespace == Some(true) {
+ let mut trimmed = String::with_capacity(text.len());
+ for (i, line) in text.split('\n').enumerate() {
+ if i > 0 {
+ trimmed.push('\n');
+ }
+ let (body, cr) = match line.strip_suffix('\r') {
+ Some(body) => (body, "\r"),
+ None => (line, ""),
+ };
+ trimmed.push_str(body.trim_end_matches([' ', '\t']));
+ trimmed.push_str(cr);
+ }
+ trimmed
+ } else {
+ text.to_owned()
+ };
+ if cfg.insert_final_newline == Some(true) && !out.is_empty() && !out.ends_with('\n') {
+ out.push('\n');
+ }
+ (out != text).then_some(out)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn round_trips(bytes: &[u8]) -> Decoded {
+ let decoded = decode(bytes).expect("text");
+ let encoded = encode(&decoded.text, &decoded.format).expect("encodable");
+ assert_eq!(encoded, bytes, "round trip of {decoded:?}");
+ decoded
+ }
+
+ // --- decoding ---
+
+ #[test]
+ fn plain_utf8_is_utf8_without_bom() {
+ let decoded = round_trips("héllo 世界\n".as_bytes());
+ assert_eq!(decoded.text, "héllo 世界\n");
+ assert_eq!(decoded.format, TextFormat::default());
+ assert_eq!(decoded.format.encoding_label(), "UTF-8");
+ }
+
+ #[test]
+ fn empty_file_is_utf8() {
+ let decoded = round_trips(b"");
+ assert_eq!(decoded.text, "");
+ assert_eq!(decoded.format, TextFormat::default());
+ }
+
+ #[test]
+ fn utf8_bom_is_stripped_and_restored() {
+ let decoded = round_trips(b"\xEF\xBB\xBFfn main() {}\n");
+ assert_eq!(decoded.text, "fn main() {}\n");
+ assert_eq!(decoded.format.encoding, UTF_8);
+ assert!(decoded.format.bom);
+ assert_eq!(decoded.format.encoding_label(), "UTF-8 BOM");
+ }
+
+ #[test]
+ fn utf16_le_and_be_boms_decode() {
+ let text = "a\u{00e9}\u{4e2d}\u{1F600}\n";
+ let mut le = vec![0xFF, 0xFE];
+ let mut be = vec![0xFE, 0xFF];
+ for unit in text.encode_utf16() {
+ le.extend_from_slice(&unit.to_le_bytes());
+ be.extend_from_slice(&unit.to_be_bytes());
+ }
+ let decoded = round_trips(&le);
+ assert_eq!(decoded.text, text);
+ assert_eq!(decoded.format.encoding, UTF_16LE);
+ assert!(decoded.format.bom);
+ assert_eq!(decoded.format.encoding_label(), "UTF-16 LE");
+
+ let decoded = round_trips(&be);
+ assert_eq!(decoded.text, text);
+ assert_eq!(decoded.format.encoding, UTF_16BE);
+ assert_eq!(decoded.format.encoding_label(), "UTF-16 BE");
+ }
+
+ #[test]
+ fn utf16_crlf_is_normalised_and_restored() {
+ let mut bytes = vec![0xFF, 0xFE];
+ for unit in "one\r\ntwo\r\n".encode_utf16() {
+ bytes.extend_from_slice(&unit.to_le_bytes());
+ }
+ let decoded = round_trips(&bytes);
+ assert_eq!(decoded.text, "one\ntwo\n");
+ assert_eq!(decoded.format.line_ending, LineEnding::CrLf);
+ }
+
+ #[test]
+ fn nul_in_the_first_8k_is_binary() {
+ assert_eq!(decode(b"\x7FELF\x02\x01\x01\x00\x00"), None);
+ let mut late = vec![b'a'; BINARY_SNIFF_LEN - 1];
+ late.push(0);
+ assert_eq!(decode(&late), None);
+ // Past the sniff window a NUL no longer decides anything.
+ let mut later = vec![b'a'; BINARY_SNIFF_LEN];
+ later.push(0);
+ assert!(decode(&later).is_some());
+ }
+
+ #[test]
+ fn gb18030_chinese_text_is_recognised() {
+ let text = "// 中文注释:你好,世界。\nfn main() {}\n";
+ let (bytes, _, unmappable) = GB18030.encode(text);
+ assert!(!unmappable);
+ assert!(std::str::from_utf8(&bytes).is_err());
+ let decoded = round_trips(&bytes);
+ assert_eq!(decoded.text, text);
+ assert_eq!(decoded.format.encoding, GB18030);
+ assert_eq!(decoded.format.encoding_label(), "GB18030");
+ }
+
+ #[test]
+ fn latin_text_falls_back_to_windows_1252() {
+ let text = "café au lait, naïve — “quoted” €5\n";
+ let (bytes, _, _) = WINDOWS_1252.encode(text);
+ let decoded = round_trips(&bytes);
+ assert_eq!(decoded.text, text);
+ assert_eq!(decoded.format.encoding, WINDOWS_1252);
+ assert_eq!(decoded.format.encoding_label(), "Windows-1252");
+ }
+
+ #[test]
+ fn latin_text_that_is_valid_gbk_is_still_windows_1252() {
+ // `é` followed by a letter is a well-formed GBK pair, so GB18030
+ // decodes this without error; the zone test must reject it.
+ let (bytes, _, _) = WINDOWS_1252.encode("résumés");
+ assert!(
+ GB18030
+ .decode_without_bom_handling_and_without_replacement(&bytes)
+ .is_some()
+ );
+ let decoded = round_trips(&bytes);
+ assert_eq!(decoded.text, "résumés");
+ assert_eq!(decoded.format.encoding, WINDOWS_1252);
+ }
+
+ #[test]
+ fn every_byte_survives_the_windows_1252_fallback() {
+ // Includes 0x81, 0x8D, 0x8F, 0x90 and 0x9D, which Windows-1252 leaves
+ // undefined and encoding_rs maps to C1 controls.
+ let bytes: Vec = (1..=255u8).collect();
+ let decoded = round_trips(&bytes);
+ assert_eq!(decoded.format.encoding, WINDOWS_1252);
+ }
+
+ // --- line endings ---
+
+ #[test]
+ fn crlf_is_normalised_and_restored() {
+ let decoded = round_trips(b"a\r\nb\r\n\r\nc");
+ assert_eq!(decoded.text, "a\nb\n\nc");
+ assert_eq!(decoded.format.line_ending, LineEnding::CrLf);
+ assert_eq!(decoded.format.line_ending.label(), "CRLF");
+ }
+
+ #[test]
+ fn mixed_endings_follow_the_majority() {
+ let decoded = decode(b"a\r\nb\r\nc\nd").unwrap();
+ assert_eq!(decoded.format.line_ending, LineEnding::CrLf);
+ assert_eq!(decoded.text, "a\nb\nc\nd");
+ // Saving makes the minority line consistent.
+ assert_eq!(
+ encode(&decoded.text, &decoded.format).unwrap(),
+ b"a\r\nb\r\nc\r\nd"
+ );
+
+ let decoded = round_trips(b"a\nb\nc\r\nd");
+ assert_eq!(decoded.format.line_ending, LineEnding::Lf);
+ assert_eq!(decoded.text, "a\nb\nc\r\nd");
+ }
+
+ #[test]
+ fn ties_and_no_breaks_are_lf() {
+ assert_eq!(round_trips(b"a\r\nb\n").format.line_ending, LineEnding::Lf);
+ assert_eq!(round_trips(b"one line").format.line_ending, LineEnding::Lf);
+ }
+
+ #[test]
+ fn lone_cr_is_left_alone() {
+ let decoded = round_trips(b"old\rmac\r");
+ assert_eq!(decoded.text, "old\rmac\r");
+ assert_eq!(decoded.format.line_ending, LineEnding::Lf);
+ // A stray CR before a CRLF survives a CRLF round trip.
+ let decoded = round_trips(b"a\r\r\nb\r\n");
+ assert_eq!(decoded.text, "a\r\nb\n");
+ }
+
+ #[test]
+ fn crlf_gb18030_round_trips() {
+ let (bytes, _, _) = GB18030.encode("第一行\r\n第二行\r\n");
+ let decoded = round_trips(&bytes);
+ assert_eq!(decoded.text, "第一行\n第二行\n");
+ assert_eq!(decoded.format.encoding, GB18030);
+ assert_eq!(decoded.format.line_ending, LineEnding::CrLf);
+ }
+
+ // --- encoding ---
+
+ #[test]
+ fn unrepresentable_character_is_named() {
+ let format = TextFormat {
+ encoding: WINDOWS_1252,
+ ..TextFormat::default()
+ };
+ let err = encode("ok é then 中 and 😀", &format).unwrap_err();
+ assert_eq!(
+ err,
+ EncodeError {
+ encoding: "Windows-1252",
+ unmappable: '中'
+ }
+ );
+ assert_eq!(err.to_string(), "can't save as Windows-1252: contains '中'");
+ }
+
+ #[test]
+ fn gb18030_encodes_all_of_unicode() {
+ let format = TextFormat {
+ encoding: GB18030,
+ ..TextFormat::default()
+ };
+ // Mostly hanzi, so the decode heuristic still recognises it with a
+ // four-byte emoji in the mix.
+ let bytes = encode("中文测试 😀 é", &format).unwrap();
+ assert_eq!(decode(&bytes).unwrap().text, "中文测试 😀 é");
+ }
+
+ #[test]
+ fn new_text_encodes_with_the_chosen_format() {
+ let format = TextFormat {
+ encoding: UTF_8,
+ bom: true,
+ line_ending: LineEnding::CrLf,
+ };
+ assert_eq!(encode("a\nb", &format).unwrap(), b"\xEF\xBB\xBFa\r\nb");
+ }
+
+ // --- indentation ---
+
+ #[test]
+ fn tab_indented_file_uses_tabs() {
+ let text = "fn a() {\n\tif x {\n\t\ty();\n\t}\n}\n";
+ assert_eq!(
+ detect_indent(text, "rust"),
+ Indent {
+ hard_tabs: true,
+ size: 4
+ }
+ );
+ }
+
+ #[test]
+ fn two_space_file() {
+ let text = "a:\n b:\n c: 1\n d: 2\n e:\n f: 3\n";
+ assert_eq!(
+ detect_indent(text, "rust"),
+ Indent {
+ hard_tabs: false,
+ size: 2
+ }
+ );
+ }
+
+ #[test]
+ fn four_space_file_with_deep_dedents_and_blank_lines() {
+ let text = "def f():\n if x:\n if y:\n\n z()\n return 1\n\ndef g():\n pass\n";
+ assert_eq!(
+ detect_indent(text, "python"),
+ Indent {
+ hard_tabs: false,
+ size: 4
+ }
+ );
+ }
+
+ #[test]
+ fn block_comment_spaces_do_not_outvote_tabs() {
+ let text = "/**\n * doc\n * more\n */\nint f() {\n\treturn 0;\n}\n";
+ assert!(detect_indent(text, "c").hard_tabs);
+ }
+
+ #[test]
+ fn no_evidence_uses_the_language_default() {
+ let flat = "a\nb\n\nc\n";
+ let tabs = Indent {
+ hard_tabs: true,
+ size: 4,
+ };
+ assert_eq!(detect_indent(flat, "make"), tabs);
+ assert_eq!(detect_indent(flat, "go"), tabs);
+ assert_eq!(
+ detect_indent(flat, "yaml"),
+ Indent {
+ hard_tabs: false,
+ size: 2
+ }
+ );
+ assert_eq!(
+ detect_indent("", "rust"),
+ Indent {
+ hard_tabs: false,
+ size: 4
+ }
+ );
+ }
+
+ #[test]
+ fn spaces_without_a_recognised_step_fall_back_to_four() {
+ // Aligned continuation lines only: a step of 3 votes for nothing.
+ let text = "foo(a,\n b)\n".replace(" ", " ");
+ assert_eq!(
+ detect_indent(&text, "go"),
+ Indent {
+ hard_tabs: false,
+ size: 4
+ }
+ );
+ }
+
+ // --- editorconfig ---
+
+ fn configs(list: &[(&str, &str)]) -> Vec<(PathBuf, String)> {
+ list.iter()
+ .map(|(dir, contents)| (PathBuf::from(dir), contents.to_string()))
+ .collect()
+ }
+
+ #[test]
+ fn basename_glob_matches_at_any_depth() {
+ let cfgs = configs(&[("/p", "[*.rs]\nindent_style = space\nindent_size = 2\n")]);
+ let cfg = editorconfig_for(Path::new("/p/src/deep/main.rs"), &cfgs);
+ assert_eq!(cfg.hard_tabs, Some(false));
+ assert_eq!(cfg.indent_size, Some(2));
+ assert_eq!(cfg.tab_width, Some(2));
+ let other = editorconfig_for(Path::new("/p/src/main.go"), &cfgs);
+ assert_eq!(other, EditorConfig::default());
+ // Not below the config's directory at all.
+ let outside = editorconfig_for(Path::new("/q/main.rs"), &cfgs);
+ assert_eq!(outside, EditorConfig::default());
+ }
+
+ #[test]
+ fn glob_with_a_slash_is_anchored() {
+ let cfgs = configs(&[(
+ "/p",
+ "[src/*.rs]\nindent_size = 3\n[/lib/**]\nend_of_line = crlf\n",
+ )]);
+ assert_eq!(
+ editorconfig_for(Path::new("/p/src/a.rs"), &cfgs).indent_size,
+ Some(3)
+ );
+ // `*` does not cross a separator, and the anchor is the config's dir.
+ assert_eq!(
+ editorconfig_for(Path::new("/p/src/x/a.rs"), &cfgs).indent_size,
+ None
+ );
+ assert_eq!(
+ editorconfig_for(Path::new("/p/sub/src/a.rs"), &cfgs).indent_size,
+ None
+ );
+ assert_eq!(
+ editorconfig_for(Path::new("/p/lib/a/b/c.txt"), &cfgs).end_of_line,
+ Some(LineEnding::CrLf)
+ );
+ assert_eq!(
+ editorconfig_for(Path::new("/p/x/lib/c.txt"), &cfgs).end_of_line,
+ None
+ );
+ }
+
+ #[test]
+ fn braces_classes_and_ranges() {
+ let cfgs = configs(&[(
+ "/p",
+ "[*.{js,ts}]\nindent_size = 2\n[file[0-9].c]\ntab_width = 8\n[v{1..3}.txt]\ninsert_final_newline = true\n[?.md]\ntrim_trailing_whitespace = false\n",
+ )]);
+ let at = |p: &str| editorconfig_for(Path::new(p), &cfgs);
+ assert_eq!(at("/p/a.ts").indent_size, Some(2));
+ assert_eq!(at("/p/web/a.js").indent_size, Some(2));
+ assert_eq!(at("/p/a.rs").indent_size, None);
+ assert_eq!(at("/p/file7.c").tab_width, Some(8));
+ assert_eq!(at("/p/fileX.c").tab_width, None);
+ assert_eq!(at("/p/v2.txt").insert_final_newline, Some(true));
+ assert_eq!(at("/p/v4.txt").insert_final_newline, None);
+ assert_eq!(at("/p/a.md").trim_trailing_whitespace, Some(false));
+ assert_eq!(at("/p/ab.md").trim_trailing_whitespace, None);
+ }
+
+ #[test]
+ fn double_star_crosses_directories() {
+ let cfgs = configs(&[("/p", "[docs/**/*.md]\nindent_size = 2\n")]);
+ let at = |p: &str| editorconfig_for(Path::new(p), &cfgs).indent_size;
+ assert_eq!(at("/p/docs/a.md"), Some(2));
+ assert_eq!(at("/p/docs/x/y/a.md"), Some(2));
+ assert_eq!(at("/p/other/a.md"), None);
+ }
+
+ #[test]
+ fn nearer_configs_override_and_root_stops_the_walk() {
+ let cfgs = configs(&[
+ ("/p/sub", "[*]\nindent_size = 8\n"),
+ (
+ "/p",
+ "root = true\n[*]\nindent_style = space\nindent_size = 2\n",
+ ),
+ (
+ "/",
+ "[*]\nend_of_line = crlf\ninsert_final_newline = true\n",
+ ),
+ ]);
+ let cfg = editorconfig_for(Path::new("/p/sub/a.txt"), &cfgs);
+ assert_eq!(cfg.indent_size, Some(8));
+ assert_eq!(cfg.hard_tabs, Some(false));
+ // The config above the root one is ignored.
+ assert_eq!(cfg.end_of_line, None);
+ assert_eq!(cfg.insert_final_newline, None);
+ }
+
+ #[test]
+ fn later_sections_override_earlier_ones() {
+ let cfgs = configs(&[("/p", "[*]\nindent_size = 4\n[*.py]\nindent_size = 2\n")]);
+ assert_eq!(
+ editorconfig_for(Path::new("/p/a.py"), &cfgs).indent_size,
+ Some(2)
+ );
+ }
+
+ #[test]
+ fn is_root_reads_only_the_preamble() {
+ assert!(is_root("# top\nROOT = TRUE\n[*]\nindent_size = 2\n"));
+ assert!(!is_root("root = false\n"));
+ assert!(!is_root("[*]\nroot = true\n"));
+ assert!(!is_root(""));
+ }
+
+ #[test]
+ fn indent_size_tab_uses_tab_width() {
+ let cfgs = configs(&[(
+ "/p",
+ "[*]\nindent_style = Tab\nindent_size = tab\ntab_width = 6\n",
+ )]);
+ let cfg = editorconfig_for(Path::new("/p/Makefile"), &cfgs);
+ assert_eq!(cfg.hard_tabs, Some(true));
+ assert_eq!(cfg.indent_size, Some(6));
+ assert_eq!(cfg.tab_width, Some(6));
+
+ // With tab indentation and no size, the size is the tab width.
+ let cfgs = configs(&[("/p", "[*]\nindent_style = tab\ntab_width = 3\n")]);
+ let cfg = editorconfig_for(Path::new("/p/a.c"), &cfgs);
+ assert_eq!(cfg.indent_size, Some(3));
+ }
+
+ #[test]
+ fn unset_clears_an_inherited_value() {
+ let cfgs = configs(&[
+ (
+ "/p/sub",
+ "[*.rs]\nindent_size = unset\nend_of_line = UNSET\n",
+ ),
+ ("/p", "[*]\nindent_size = 2\nend_of_line = crlf\n"),
+ ]);
+ let cfg = editorconfig_for(Path::new("/p/sub/a.rs"), &cfgs);
+ assert_eq!(cfg.indent_size, None);
+ assert_eq!(cfg.end_of_line, None);
+ }
+
+ #[test]
+ fn unknown_keys_and_bad_values_are_ignored() {
+ let cfgs = configs(&[(
+ "/p",
+ "; comment\n[*]\nmax_line_length = 80\nindent_size = zero\nend_of_line = cr\ninsert_final_newline = maybe\n",
+ )]);
+ assert_eq!(
+ editorconfig_for(Path::new("/p/a"), &cfgs),
+ EditorConfig::default()
+ );
+ }
+
+ #[test]
+ fn config_overrides_detected_indent() {
+ let detected = Indent {
+ hard_tabs: false,
+ size: 2,
+ };
+ assert_eq!(EditorConfig::default().indent(detected), detected);
+ let tabs = EditorConfig {
+ hard_tabs: Some(true),
+ tab_width: Some(8),
+ indent_size: Some(4),
+ ..EditorConfig::default()
+ };
+ assert_eq!(
+ tabs.indent(detected),
+ Indent {
+ hard_tabs: true,
+ size: 8
+ }
+ );
+ let size_only = EditorConfig {
+ indent_size: Some(4),
+ ..EditorConfig::default()
+ };
+ assert_eq!(
+ size_only.indent(detected),
+ Indent {
+ hard_tabs: false,
+ size: 4
+ }
+ );
+ }
+
+ // --- save rules ---
+
+ #[test]
+ fn save_rules_trim_and_add_final_newline() {
+ let both = EditorConfig {
+ trim_trailing_whitespace: Some(true),
+ insert_final_newline: Some(true),
+ ..EditorConfig::default()
+ };
+ assert_eq!(
+ apply_save_rules("a \nb\t\n c ", &both).as_deref(),
+ Some("a\nb\n c\n")
+ );
+ // A CR kept by an LF file stays put; whitespace before it goes.
+ assert_eq!(
+ apply_save_rules("a \r\nb\n", &both).as_deref(),
+ Some("a\r\nb\n")
+ );
+ assert_eq!(apply_save_rules("clean\n", &both), None);
+ assert_eq!(apply_save_rules("", &both), None);
+ }
+
+ #[test]
+ fn save_rules_respect_false_and_unset() {
+ let off = EditorConfig {
+ trim_trailing_whitespace: Some(false),
+ insert_final_newline: Some(false),
+ ..EditorConfig::default()
+ };
+ assert_eq!(apply_save_rules("a \nb", &off), None);
+ assert_eq!(apply_save_rules("a \n", &EditorConfig::default()), None);
+ let add_only = EditorConfig {
+ insert_final_newline: Some(true),
+ ..EditorConfig::default()
+ };
+ assert_eq!(apply_save_rules("a ", &add_only).as_deref(), Some("a \n"));
+ }
+}
diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs
index 07ac1027..30cc6fd1 100644
--- a/src/ui/file_tree.rs
+++ b/src/ui/file_tree.rs
@@ -207,6 +207,10 @@ pub(crate) struct FileTreeState {
repo_roots: ByHost,
repo_root_loads: InFlight,
search: SearchState,
+ /// Every file under the project, for the search's Files tab
+ /// (`ui::search::files`). Kept here, beside the tree it is a flat copy of,
+ /// so it outlives the search it was walked for.
+ pub(crate) quick_open: crate::ui::search::FileIndexStore,
pub(crate) show_hidden: bool,
pub(crate) editing: Option,
editing_subs: Vec,
@@ -250,6 +254,7 @@ impl FileTreeState {
repo_roots: ByHost::default(),
repo_root_loads: InFlight::default(),
search: SearchState::default(),
+ quick_open: Default::default(),
show_hidden: false,
editing: None,
editing_subs: Vec::new(),
@@ -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>, 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, 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::().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,
diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs
index ee288b79..170cb096 100644
--- a/src/ui/i18n/en.rs
+++ b/src/ui/i18n/en.rs
@@ -125,10 +125,21 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SearchTabActions => "Actions",
L10nKey::SearchTabTerminals => "Terminals",
L10nKey::SearchTabHosts => "Hosts",
- L10nKey::SearchPlaceholderAll => "Search actions, terminals and hosts…",
+ L10nKey::SearchPlaceholderAll => "Search files, actions, terminals and hosts…",
L10nKey::SearchPlaceholderActions => "Search actions…",
L10nKey::SearchPlaceholderTerminals => "Search open tabs, shells and agents…",
L10nKey::SearchPlaceholderHosts => "Search hosts, or type user@host to connect…",
+ L10nKey::SearchTabFiles => "Files",
+ L10nKey::SearchPlaceholderFiles => "Go to a file by name — add :line to jump to it…",
+ L10nKey::SearchFilesNoRoots => "No project to search",
+ L10nKey::SearchFilesNoRootsHint => {
+ "Files are found in the project your terminal is in. cd into one to search it."
+ }
+ L10nKey::SearchFilesIndexing => "Indexing files…",
+ L10nKey::SearchFilesFailed => "The project's files could not be listed.",
+ L10nKey::SearchFilesGoToLine => "line {line}",
+ L10nKey::SearchFilesCapped => "Large project — only the first {count} files are searched",
+ L10nKey::CmdGoToFile => "Go to File…",
L10nKey::SearchTabSessions => "Sessions",
L10nKey::SearchPlaceholderSessions => "Search past agent sessions…",
L10nKey::SearchSessionsEmptyHint => {
@@ -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",
diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs
index a397593c..befbe27e 100644
--- a/src/ui/i18n/ja.rs
+++ b/src/ui/i18n/ja.rs
@@ -126,10 +126,23 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SearchTabActions => "アクション",
L10nKey::SearchTabTerminals => "ターミナル",
L10nKey::SearchTabHosts => "ホスト",
- L10nKey::SearchPlaceholderAll => "アクション、ターミナル、ホストを検索…",
+ L10nKey::SearchPlaceholderAll => "ファイル、アクション、ターミナル、ホストを検索…",
L10nKey::SearchPlaceholderActions => "アクションを検索…",
L10nKey::SearchPlaceholderTerminals => "開いているタブ、シェル、エージェントを検索…",
L10nKey::SearchPlaceholderHosts => "ホストを検索、または user@host を入力して接続…",
+ L10nKey::SearchTabFiles => "ファイル",
+ L10nKey::SearchPlaceholderFiles => "名前でファイルに移動(:行番号 でその行へ)…",
+ L10nKey::SearchFilesNoRoots => "検索するプロジェクトがありません",
+ L10nKey::SearchFilesNoRootsHint => {
+ "ファイルはターミナルのいるプロジェクトから探します。プロジェクトに cd すると検索できます。"
+ }
+ L10nKey::SearchFilesIndexing => "ファイルをインデックス中…",
+ L10nKey::SearchFilesFailed => "このプロジェクトのファイルを一覧できませんでした。",
+ L10nKey::SearchFilesGoToLine => "{line} 行目",
+ L10nKey::SearchFilesCapped => {
+ "大きなプロジェクトのため、最初の {count} 件のファイルのみ検索します"
+ }
+ L10nKey::CmdGoToFile => "ファイルに移動…",
L10nKey::SearchTabSessions => "セッション",
L10nKey::SearchPlaceholderSessions => "過去のエージェントセッションを検索…",
L10nKey::SearchSessionsEmptyHint => {
@@ -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 => "変更",
diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs
index 69724def..1d11ffb8 100644
--- a/src/ui/i18n/mod.rs
+++ b/src/ui/i18n/mod.rs
@@ -178,6 +178,15 @@ l10n_keys! {
SearchPlaceholderActions,
SearchPlaceholderTerminals,
SearchPlaceholderHosts,
+ SearchTabFiles,
+ SearchPlaceholderFiles,
+ SearchFilesNoRoots,
+ SearchFilesNoRootsHint,
+ SearchFilesIndexing,
+ SearchFilesFailed,
+ SearchFilesGoToLine,
+ SearchFilesCapped,
+ CmdGoToFile,
SearchTabSessions,
SearchPlaceholderSessions,
SearchSessionsEmptyHint,
@@ -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,
diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs
index fcdac895..0f32d258 100644
--- a/src/ui/i18n/zh.rs
+++ b/src/ui/i18n/zh.rs
@@ -110,10 +110,19 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SearchTabActions => "操作",
L10nKey::SearchTabTerminals => "终端",
L10nKey::SearchTabHosts => "主机",
- L10nKey::SearchPlaceholderAll => "搜索操作、终端和主机…",
+ L10nKey::SearchPlaceholderAll => "搜索文件、操作、终端和主机…",
L10nKey::SearchPlaceholderActions => "搜索操作…",
L10nKey::SearchPlaceholderTerminals => "搜索已打开的标签页、Shell 和 Agent…",
L10nKey::SearchPlaceholderHosts => "搜索主机,或输入 user@host 连接…",
+ L10nKey::SearchTabFiles => "文件",
+ L10nKey::SearchPlaceholderFiles => "按名称转到文件,加上 :行号 可直接跳转…",
+ L10nKey::SearchFilesNoRoots => "没有可搜索的项目",
+ L10nKey::SearchFilesNoRootsHint => "文件在终端所在的项目中查找。cd 进入一个项目即可搜索。",
+ L10nKey::SearchFilesIndexing => "正在索引文件…",
+ L10nKey::SearchFilesFailed => "无法列出这个项目的文件。",
+ L10nKey::SearchFilesGoToLine => "第 {line} 行",
+ L10nKey::SearchFilesCapped => "大型项目——只搜索前 {count} 个文件",
+ L10nKey::CmdGoToFile => "转到文件…",
L10nKey::SearchTabSessions => "会话",
L10nKey::SearchPlaceholderSessions => "搜索历史 Agent 会话…",
L10nKey::SearchSessionsEmptyHint => "这里列出本机上各编码 agent 的历史会话。",
@@ -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 => "更改",
diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs
index 40db08a5..fe4ec1e2 100644
--- a/src/ui/keymap.rs
+++ b/src/ui/keymap.rs
@@ -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 {
"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 {
"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]
diff --git a/src/ui/mod.rs b/src/ui/mod.rs
index 0bf5aa0b..8719f01c 100644
--- a/src/ui/mod.rs
+++ b/src/ui/mod.rs
@@ -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;
diff --git a/src/ui/search/command.rs b/src/ui/search/command.rs
index c0952e13..9214b07b 100644
--- a/src/ui/search/command.rs
+++ b/src/ui/search/command.rs
@@ -107,6 +107,15 @@ pub enum CommandKind {
OpenThemePicker,
/// Moves the search to its Hosts tab, where typing an address connects.
SearchHosts,
+ /// Moves the search to its Files tab — quick open by name.
+ QuickOpenFile,
+ /// A file the Files tab found, opened in the editor — on `line` and
+ /// `column` when the query named them (`main.rs:120:5`).
+ OpenFile {
+ path: std::path::PathBuf,
+ line: Option,
+ column: Option,
+ },
/// Connect with a typed `ssh` command line (`-p`, `-J`, an alias…).
OpenSshConnect(String),
SetTheme(usize),
@@ -254,11 +263,13 @@ impl CommandKind {
SendGitDiffToAgent => "agent-send-diff",
OpenThemePicker => "change-theme",
SearchHosts => "ssh-add-connection",
+ QuickOpenFile => "go-to-file",
OpenSshProfiles => "ssh-manage-profiles",
SaveSshSessionAsHost => "ssh-save-connection",
OpenSshConnect(_)
| SetTheme(_)
| GoToTab { .. }
+ | OpenFile { .. }
| ResumeSession { .. }
| ForkSession { .. }
| CopySessionId(_)
@@ -377,6 +388,7 @@ impl CommandKind {
ScmCreateBranch => "ScmCreateBranch",
OpenBranchPicker => "ScmCheckoutBranch",
ToggleDiffViewMode => "ToggleDiffViewMode",
+ QuickOpenFile => "QuickOpenFile",
CopyText
| CutText
| PasteText
@@ -393,6 +405,7 @@ impl CommandKind {
| OpenSshConnect(_)
| SetTheme(_)
| GoToTab { .. }
+ | OpenFile { .. }
| ResumeSession { .. }
| ForkSession { .. }
| CopySessionId(_)
@@ -628,6 +641,7 @@ impl Item {
ToggleRightPanel,
),
Item::localized(L10nKey::CmdShowCodePanel, ToggleCodePanel),
+ Item::localized(L10nKey::CmdGoToFile, QuickOpenFile),
Item::localized(
if document_filled {
L10nKey::CmdDocumentDock
diff --git a/src/ui/search/files.rs b/src/ui/search/files.rs
new file mode 100644
index 00000000..8c1fc8f4
--- /dev/null
+++ b/src/ui/search/files.rs
@@ -0,0 +1,743 @@
+//! The Files tab: open a file in the editor by typing part of its name, the
+//! way ⌘P does in VS Code, instead of walking the tree to it.
+//!
+//! The list comes from one walk of the project behind the active tab's panes,
+//! done on the host that owns them through [`Host::search`]
+//! — the same breadth-first, `.gitignore`-aware walk the Files panel's filter
+//! runs, asked for every name at once. It is kept between openings and shown
+//! at once when the search opens again, while a fresh walk runs behind it
+//! (stale-while-revalidate): the list you already had is a better answer
+//! than a spinner, and the one that replaces it lands under the cursor
+//! without moving the highlighted row.
+
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use gpui::{App, Context, Window};
+
+use super::SearchTab;
+use super::command::{CommandKind, Item};
+use super::score::fuzzy_score;
+use super::sources::{Row, Section, Source};
+use crate::ui::app::Tty7App;
+use crate::ui::host_ops::{Host, HostId, HostOps};
+use crate::ui::i18n::{L10nKey, t_fmt};
+
+/// How many entries one walk may return, directories included. A project
+/// past this is mostly generated or vendored files, and a list the size of
+/// the cap is already more than anyone scrolls; the walk is breadth-first, so
+/// what the cap cuts off is the deepest part of the tree.
+pub(crate) const INDEX_LIMIT: usize = 50_000;
+
+/// How many directories one walk may open. Bounds a walk over a slow link as
+/// much as it bounds the list: each directory is a round trip on a remote host.
+pub(crate) const INDEX_MAX_DIRS: usize = 20_000;
+
+/// A search that opens again this soon after a walk landed reuses it rather
+/// than walking again. Opening and closing the search in quick succession is
+/// someone looking for the right tab, not a sign the tree changed.
+const REVALIDATE_AFTER: Duration = Duration::from_secs(5);
+
+/// Rows the Files tab shows before anything is typed. A sample, shallowest
+/// first, so the tab is visibly populated; the query is what finds things.
+const BROWSE_ROWS: usize = 100;
+
+/// Rows a query may bring back. Past this the ranking is noise, and every row
+/// is an `Item` built on each keystroke.
+const SEARCH_ROWS: usize = 200;
+
+/// What a match only on the directory part of a path gives up against one on
+/// the file's own name. Typing `app` means `app.rs` far more often than
+/// `apps/web/index.ts`, but a query with a `/` in it can only match the path,
+/// and those still have to come back.
+const PATH_ONLY_PENALTY: i32 = 15;
+
+/// One file the walk found.
+#[derive(Debug)]
+pub(crate) struct IndexedFile {
+ /// Where it is on the host, as the host spells it.
+ pub path: PathBuf,
+ /// Where it is under its project root — the text the query matches and
+ /// the row shows. With more than one root it starts with the root's name,
+ /// so two `src/main.rs` in two projects can be told apart.
+ pub rel: String,
+ /// Where the file's own name starts in `rel`.
+ pub name_at: usize,
+ /// `rel` lowercased once, for the prefilter that runs on every keystroke.
+ lower: String,
+}
+
+impl IndexedFile {
+ pub(crate) fn name(&self) -> &str {
+ &self.rel[self.name_at..]
+ }
+
+ /// The directory part of `rel`, without its trailing separator. Empty for
+ /// a file at the root.
+ pub(crate) fn dir(&self) -> &str {
+ self.rel[..self.name_at].trim_end_matches(['/', '\\'])
+ }
+}
+
+#[derive(Debug, Default)]
+pub(crate) struct FileIndex {
+ /// In the order the walk found them: shallowest first.
+ pub files: Vec,
+ /// Whether the walk stopped at [`INDEX_LIMIT`]. One cut short by
+ /// [`INDEX_MAX_DIRS`] cannot tell, and is not flagged.
+ pub capped: bool,
+}
+
+/// What the Files tab has to offer right now.
+#[derive(Clone, Debug, Default)]
+pub(crate) enum FileList {
+ /// No pane has a directory worth searching: none reported one, or the one
+ /// it reported is the home directory, which is not a project and whose
+ /// walk would be all caches and downloads.
+ #[default]
+ NoRoots,
+ /// The first walk of these folders has not come back yet.
+ Indexing,
+ /// The host could not walk them.
+ Failed,
+ Ready(Arc),
+}
+
+/// A query as the Files tab reads it: the name to look for, and the place in
+/// the file to land on when `:line` or `:line:column` follows it — what a
+/// compiler prints and what people paste.
+#[derive(Debug, PartialEq, Eq)]
+pub(crate) struct FileQuery<'a> {
+ pub needle: &'a str,
+ pub line: Option,
+ pub column: Option,
+}
+
+pub(crate) fn parse_query(query: &str) -> FileQuery<'_> {
+ let query = query.trim();
+ // A trailing colon is a line number on its way; it is not part of a name.
+ let mut rest = query.strip_suffix(':').unwrap_or(query);
+ let mut numbers: Vec = Vec::new();
+ while numbers.len() < 2 {
+ let Some((head, tail)) = rest.rsplit_once(':') else {
+ break;
+ };
+ if head.is_empty() || tail.is_empty() || !tail.bytes().all(|b| b.is_ascii_digit()) {
+ break;
+ }
+ let Ok(n) = tail.parse::() else { break };
+ numbers.push(n);
+ rest = head;
+ }
+ // Read right to left, so the last number found is the line.
+ numbers.reverse();
+ let nonzero = |n: Option<&u32>| n.copied().filter(|n| *n > 0);
+ FileQuery {
+ needle: rest,
+ line: nonzero(numbers.first()),
+ column: nonzero(numbers.get(1)),
+ }
+}
+
+/// The folders to walk, from the ones the panes resolved to.
+///
+/// Home and a filesystem root are left out: neither is a project, and a walk
+/// of either spends the whole budget on caches before it reaches anything
+/// anyone meant to open. A folder inside another one on the list is left out
+/// too — the outer walk already covers it, and listing it twice would put
+/// every file in the results twice.
+pub(crate) fn project_roots(resolved: Vec, home: Option<&Path>) -> Vec {
+ let mut roots: Vec = Vec::new();
+ for root in resolved {
+ let is_home = home.is_some_and(|home| root == home);
+ let is_fs_root = root.parent().is_none();
+ if is_home || is_fs_root || roots.contains(&root) {
+ continue;
+ }
+ roots.push(root);
+ }
+ let all = roots.clone();
+ roots.retain(|r| !all.iter().any(|other| other != r && r.starts_with(other)));
+ roots
+}
+
+/// The index for `files` found under `roots`.
+pub(crate) fn build_index(roots: &[PathBuf], files: Vec, capped: bool) -> FileIndex {
+ let many = roots.len() > 1;
+ let files = files
+ .into_iter()
+ .filter_map(|path| {
+ let text = path.to_string_lossy().into_owned();
+ // By text rather than `Path::strip_prefix`: a remote host's paths
+ // may be Windows paths this client parses as one component.
+ let (root, under) = roots
+ .iter()
+ .filter_map(|root| {
+ let root = root.to_string_lossy();
+ let under = text.strip_prefix(root.as_ref())?;
+ let under = under.trim_start_matches(['/', '\\']);
+ // `/repo-old/x` is not under `/repo`.
+ (under.len() < text.len() - root.len() || root.ends_with(['/', '\\']))
+ .then(|| (root.into_owned(), under.to_string()))
+ })
+ .max_by_key(|(root, _)| root.len())?;
+ if under.is_empty() {
+ return None;
+ }
+ let rel = match many {
+ true => {
+ let name = root
+ .trim_end_matches(['/', '\\'])
+ .rsplit(['/', '\\'])
+ .next()
+ .unwrap_or_default();
+ format!("{name}/{under}")
+ }
+ false => under,
+ };
+ let name_at = rel.rfind(['/', '\\']).map_or(0, |i| i + 1);
+ let lower = rel.to_lowercase();
+ Some(IndexedFile {
+ path,
+ rel,
+ name_at,
+ lower,
+ })
+ })
+ .collect();
+ FileIndex { files, capped }
+}
+
+/// Whether every character of `needle` (already lowercase, no whitespace)
+/// appears in `hay` in order. Cheap enough to run over the whole index on
+/// every keystroke, and it throws out nearly everything before the real
+/// scorer, which allocates, sees it.
+fn is_subsequence(needle: &[char], hay: &str) -> bool {
+ let mut want = needle.iter().peekable();
+ for c in hay.chars() {
+ match want.peek() {
+ Some(w) if **w == c => {
+ want.next();
+ }
+ Some(_) => {}
+ None => break,
+ }
+ }
+ want.peek().is_none()
+}
+
+/// How well `file` answers `needle`, on the one scale every tab shares — so
+/// the All tab can put a file beside an action and have the order mean
+/// something. A hit on the file's own name wins over the same hit spread
+/// across its directories.
+pub(crate) fn file_score(needle: &str, file: &IndexedFile) -> Option {
+ let on_name = fuzzy_score(needle, file.name());
+ let on_path = fuzzy_score(needle, &file.rel).map(|s| s - PATH_ONLY_PENALTY);
+ on_name.into_iter().chain(on_path).max()
+}
+
+/// The files that match `needle`, best first, at most `limit` of them. Ties
+/// go to the shorter path — the shallower file is the likelier one.
+pub(crate) fn rank<'a>(
+ index: &'a FileIndex,
+ needle: &str,
+ limit: usize,
+) -> Vec<(i32, &'a IndexedFile)> {
+ let chars: Vec = needle
+ .chars()
+ .flat_map(char::to_lowercase)
+ .filter(|c| !c.is_whitespace())
+ .collect();
+ if chars.is_empty() {
+ return Vec::new();
+ }
+ let mut hits: Vec<(i32, &IndexedFile)> = index
+ .files
+ .iter()
+ .filter(|f| is_subsequence(&chars, &f.lower))
+ .filter_map(|f| Some((file_score(needle, f)?, f)))
+ .collect();
+ hits.sort_by(|(a, fa), (b, fb)| b.cmp(a).then_with(|| fa.rel.len().cmp(&fb.rel.len())));
+ hits.truncate(limit);
+ hits
+}
+
+fn file_item(file: &IndexedFile, line: Option, column: Option) -> Item {
+ let mut item = Item::new(
+ file.name(),
+ CommandKind::OpenFile {
+ path: file.path.clone(),
+ line,
+ column,
+ },
+ );
+ if !file.dir().is_empty() {
+ item = item.with_subtitle(file.dir());
+ }
+ if let Some(line) = line {
+ item = item.with_note(t_fmt(
+ L10nKey::SearchFilesGoToLine,
+ &[("line", &line.to_string())],
+ ));
+ }
+ item
+}
+
+pub(super) struct Files<'a>(pub &'a FileList);
+
+impl Source for Files<'_> {
+ fn tab(&self) -> SearchTab {
+ SearchTab::Files
+ }
+
+ fn browse(&self, _cx: &App) -> Vec {
+ let FileList::Ready(index) = self.0 else {
+ return Vec::new();
+ };
+ let rows: Vec = index
+ .files
+ .iter()
+ .take(BROWSE_ROWS)
+ .map(|f| Row::Item(file_item(f, None, None)))
+ .collect();
+ if rows.is_empty() {
+ return Vec::new();
+ }
+ // A walk that hit its cap left the deepest files out, and a search
+ // that cannot find one of them should not look like a missing file.
+ let title = index.capped.then(|| {
+ t_fmt(
+ L10nKey::SearchFilesCapped,
+ &[("count", &index.files.len().to_string())],
+ )
+ .into()
+ });
+ vec![Section { title, rows }]
+ }
+
+ fn highlights(&self, _cx: &App) -> Vec- {
+ Vec::new()
+ }
+
+ /// A sample of the project is not an answer to anything, and on the All
+ /// tab it would push the tabs you were just in down the page.
+ fn on_the_empty_all_tab(&self) -> bool {
+ false
+ }
+
+ fn search(&self, query: &str, _cx: &App) -> Vec<(i32, Item)> {
+ let FileList::Ready(index) = self.0 else {
+ return Vec::new();
+ };
+ let q = parse_query(query);
+ rank(index, q.needle, SEARCH_ROWS)
+ .into_iter()
+ .map(|(score, f)| (score, file_item(f, q.line, q.column)))
+ .collect()
+ }
+}
+
+/// Which folders a walk is of: the host, and the directories the active tab's
+/// panes are in. The walk resolves them to their repositories itself, on the
+/// host, so the key is what the UI thread can know without asking.
+#[derive(Clone, Debug, PartialEq, Eq)]
+struct IndexKey {
+ host: HostId,
+ cwds: Vec
,
+}
+
+struct Walked {
+ key: IndexKey,
+ list: FileList,
+ at: Instant,
+}
+
+/// The window's file index, kept between openings of the search.
+#[derive(Default)]
+pub(crate) struct FileIndexStore {
+ last: Option,
+ /// The walk in flight, if any: its generation and what it is walking.
+ /// A walk that lands after a newer one started is dropped.
+ walking: Option<(u64, IndexKey)>,
+ generation: u64,
+}
+
+/// Walks the project the panes in `cwds` are in, on the host that has it.
+/// Runs off the UI thread.
+fn walk(h: &dyn Host, cwds: &[PathBuf], home: Option<&Path>) -> FileList {
+ let resolved = cwds
+ .iter()
+ .map(|cwd| {
+ h.repo_root(cwd)
+ .ok()
+ .flatten()
+ .unwrap_or_else(|| cwd.clone())
+ })
+ .collect();
+ let roots = project_roots(resolved, home);
+ if roots.is_empty() {
+ return FileList::NoRoots;
+ }
+ // Hidden and gitignored entries stay out, as they do in the tree by
+ // default: that is what keeps `.git`, `target/` and `node_modules/` from
+ // filling the cap before the sources. The empty query matches every name.
+ match h.search(&roots, "", INDEX_LIMIT, INDEX_MAX_DIRS, false) {
+ Ok(hits) => {
+ let capped = hits.len() >= INDEX_LIMIT;
+ let files = hits
+ .into_iter()
+ .filter(|hit| !hit.is_dir)
+ .map(|hit| hit.path)
+ .collect();
+ FileList::Ready(Arc::new(build_index(&roots, files, capped)))
+ }
+ Err(e) => {
+ log::warn!("quick open: walking {roots:?}: {e}");
+ FileList::Failed
+ }
+ }
+}
+
+impl Tty7App {
+ /// Search Everywhere on its Files tab — or closed, when that is what it
+ /// is already showing, so the chord that opens it also puts it away.
+ pub(crate) fn quick_open_file(&mut self, window: &mut Window, cx: &mut Context) {
+ if let Some(view) = self.search.clone() {
+ if view.read(cx).tab() == SearchTab::Files {
+ self.close_search(window, cx);
+ } else {
+ view.update(cx, |view, cx| {
+ view.set_tab(SearchTab::Files, None, window, cx)
+ });
+ }
+ return;
+ }
+ self.open_search(SearchTab::Files, "", window, cx);
+ }
+
+ /// Opens a row of the Files tab, and has the tree follow along the way a
+ /// file link in the grid does.
+ pub(crate) fn open_indexed_file(
+ &mut self,
+ path: &Path,
+ line: Option,
+ column: Option,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.open_file_in_editor_at(path, line, column, window, cx);
+ self.file_tree_reveal_path(path, cx);
+ }
+
+ fn file_index_key(&self, cx: &App) -> IndexKey {
+ let host = self.spawn_host(cx);
+ let mut cwds: Vec = self
+ .tabs
+ .get(self.active)
+ .map(|tab| tab.pane.terminals())
+ .unwrap_or_default()
+ .iter()
+ .filter(|leaf| leaf.read(cx).host_id() == host)
+ // `files_cwd`, for the reason the Files panel roots itself there:
+ // it follows an agent into its worktree, and it spells a WSL
+ // pane's directory the way the host can read it.
+ .filter_map(|leaf| leaf.read(cx).files_cwd())
+ .collect();
+ cwds.sort();
+ cwds.dedup();
+ IndexKey { host, cwds }
+ }
+
+ /// What the Files tab opens on: the last walk, if it was of the folders
+ /// the panes are in now.
+ pub(crate) fn file_list_now(&self, cx: &App) -> FileList {
+ let key = self.file_index_key(cx);
+ if key.cwds.is_empty() {
+ return FileList::NoRoots;
+ }
+ match &self.file_tree.quick_open.last {
+ Some(last) if last.key == key => last.list.clone(),
+ _ => FileList::Indexing,
+ }
+ }
+
+ /// Walks the project again, unless a walk of it just landed or is
+ /// already on its way, and hands the result to the open search.
+ pub(crate) fn refresh_file_index(&mut self, window: &mut Window, cx: &mut Context) {
+ let key = self.file_index_key(cx);
+ if key.cwds.is_empty() {
+ return;
+ }
+ let store = &self.file_tree.quick_open;
+ if store.walking.as_ref().is_some_and(|(_, k)| *k == key) {
+ return;
+ }
+ if store
+ .last
+ .as_ref()
+ .is_some_and(|l| l.key == key && l.at.elapsed() < REVALIDATE_AFTER)
+ {
+ return;
+ }
+ let Some(host) = self.active_host(cx) else {
+ return;
+ };
+ // Only this computer's home is known here, and it is the common case:
+ // a fresh local tab starts there.
+ let home = key
+ .host
+ .is_local()
+ .then(|| std::env::var_os("HOME").map(PathBuf::from))
+ .flatten();
+ let store = &mut self.file_tree.quick_open;
+ store.generation += 1;
+ let generation = store.generation;
+ store.walking = Some((generation, key.clone()));
+ let cwds = key.cwds.clone();
+ HostOps::run_in(
+ host,
+ window,
+ cx,
+ move |h| walk(h, &cwds, home.as_deref()),
+ move |app, list, window, cx| {
+ let store = &mut app.file_tree.quick_open;
+ if store.walking.as_ref().map(|(g, _)| *g) != Some(generation) {
+ return;
+ }
+ store.walking = None;
+ // A revalidation that failed keeps the list it was checking:
+ // yesterday's files are still mostly where they were.
+ let keep_old = matches!(list, FileList::Failed)
+ && store
+ .last
+ .as_ref()
+ .is_some_and(|l| l.key == key && matches!(l.list, FileList::Ready(_)));
+ if !keep_old {
+ store.last = Some(Walked {
+ key: key.clone(),
+ list,
+ at: Instant::now(),
+ });
+ }
+ if app.file_index_key(cx) != key {
+ return;
+ }
+ let list = app.file_list_now(cx);
+ if let Some(view) = app.search.clone() {
+ view.update(cx, |view, cx| view.set_files(list, window, cx));
+ }
+ },
+ );
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn index(paths: &[&str]) -> FileIndex {
+ build_index(
+ &[PathBuf::from("/repo")],
+ paths
+ .iter()
+ .map(|p| PathBuf::from(format!("/repo/{p}")))
+ .collect(),
+ false,
+ )
+ }
+
+ fn ranked(index: &FileIndex, query: &str) -> Vec {
+ rank(index, parse_query(query).needle, 50)
+ .into_iter()
+ .map(|(_, f)| f.rel.clone())
+ .collect()
+ }
+
+ #[test]
+ fn a_query_can_name_a_line_and_a_column() {
+ let q = |s| parse_query(s);
+ assert_eq!(
+ q("main.rs"),
+ FileQuery {
+ needle: "main.rs",
+ line: None,
+ column: None
+ }
+ );
+ assert_eq!(
+ q("main.rs:120"),
+ FileQuery {
+ needle: "main.rs",
+ line: Some(120),
+ column: None
+ }
+ );
+ assert_eq!(
+ q("src/main.rs:120:7"),
+ FileQuery {
+ needle: "src/main.rs",
+ line: Some(120),
+ column: Some(7)
+ }
+ );
+ // Half typed: the colon is on its way to a number.
+ assert_eq!(q("main.rs:").needle, "main.rs");
+ assert_eq!(q("main.rs:12:").line, Some(12));
+ // Not a number, so part of the name.
+ assert_eq!(q("std::fs").needle, "std::fs");
+ assert_eq!(q("a:b12").needle, "a:b12");
+ // A bare number is a name to look for, not a line in nothing.
+ assert_eq!(q("404").needle, "404");
+ assert_eq!(q(":12").needle, ":12");
+ // Line 0 does not exist.
+ assert_eq!(q("x.rs:0").line, None);
+ assert_eq!(q(" x.rs:3 ").line, Some(3));
+ }
+
+ #[test]
+ fn a_match_on_the_name_beats_the_same_letters_in_the_path() {
+ let idx = index(&["apps/web/index.ts", "src/app.rs", "src/ui/app.rs"]);
+ let got = ranked(&idx, "app");
+ assert_eq!(
+ &got[..2],
+ ["src/app.rs", "src/ui/app.rs"],
+ "the shorter of two equal name hits leads"
+ );
+ assert_eq!(got[2], "apps/web/index.ts");
+ }
+
+ #[test]
+ fn an_exact_name_outranks_a_longer_one() {
+ let idx = index(&["src/ui/main_window.rs", "src/main.rs"]);
+ assert_eq!(ranked(&idx, "main.rs")[0], "src/main.rs");
+ }
+
+ #[test]
+ fn a_path_query_finds_files_by_their_directories() {
+ let idx = index(&["src/ui/search/view.rs", "src/ui/view.rs", "docs/view.md"]);
+ let got = ranked(&idx, "search/view");
+ assert_eq!(got, ["src/ui/search/view.rs"]);
+ let got = ranked(&idx, "ui view");
+ assert_eq!(got.len(), 2, "whitespace is ignored, as everywhere else");
+ }
+
+ #[test]
+ fn nothing_that_is_not_a_subsequence_matches() {
+ let idx = index(&["src/main.rs"]);
+ assert!(ranked(&idx, "zzz").is_empty());
+ assert!(ranked(&idx, "").is_empty(), "an empty query ranks nothing");
+ }
+
+ #[test]
+ fn rows_split_into_a_name_and_the_directory_it_is_in() {
+ let idx = index(&["src/ui/app.rs", "README.md"]);
+ assert_eq!(idx.files[0].name(), "app.rs");
+ assert_eq!(idx.files[0].dir(), "src/ui");
+ assert_eq!(idx.files[1].name(), "README.md");
+ assert_eq!(idx.files[1].dir(), "");
+ }
+
+ #[test]
+ fn two_roots_are_told_apart_by_name() {
+ let idx = build_index(
+ &[PathBuf::from("/work/api"), PathBuf::from("/work/web")],
+ vec![
+ PathBuf::from("/work/api/src/main.rs"),
+ PathBuf::from("/work/web/src/main.rs"),
+ // A sibling whose name merely starts with a root's is not in it.
+ PathBuf::from("/work/api-old/x.rs"),
+ ],
+ false,
+ );
+ let rels: Vec<_> = idx.files.iter().map(|f| f.rel.as_str()).collect();
+ assert_eq!(rels, ["api/src/main.rs", "web/src/main.rs"]);
+ }
+
+ #[test]
+ fn a_windows_path_splits_on_its_own_separator() {
+ let idx = build_index(
+ &[PathBuf::from(r"C:\repo")],
+ vec![PathBuf::from(r"C:\repo\src\lib.rs")],
+ false,
+ );
+ assert_eq!(idx.files[0].rel, r"src\lib.rs");
+ assert_eq!(idx.files[0].name(), "lib.rs");
+ assert_eq!(idx.files[0].dir(), "src");
+ }
+
+ #[test]
+ fn home_and_the_filesystem_root_are_not_projects() {
+ let home = PathBuf::from("/home/me");
+ let roots = project_roots(
+ vec![
+ home.clone(),
+ PathBuf::from("/"),
+ PathBuf::from("/home/me/repo"),
+ PathBuf::from("/home/me/repo"),
+ PathBuf::from("/home/me/repo/crates/core"),
+ PathBuf::from("/srv/other"),
+ ],
+ Some(&home),
+ );
+ assert_eq!(
+ roots,
+ [PathBuf::from("/home/me/repo"), PathBuf::from("/srv/other")],
+ "home and / go, a duplicate goes, and a folder inside another is covered by it"
+ );
+ assert!(project_roots(vec![home.clone()], Some(&home)).is_empty());
+ }
+
+ /// The walk goes through the host, from the pane's directory up to its
+ /// repository, and leaves out what the tree hides by default.
+ #[test]
+ fn the_walk_indexes_the_repository_without_what_git_ignores() {
+ let host = tty7_core::host::local::LocalHost::new();
+ let tmp = std::env::temp_dir().join(format!("tty7-quick-open-{}", std::process::id()));
+ let _ = host.remove(&tmp, true);
+ host.create_dir(&tmp.join(".git"), true).unwrap();
+ host.create_dir(&tmp.join("src/ui"), true).unwrap();
+ host.create_dir(&tmp.join("target/debug"), true).unwrap();
+ host.write_file(&tmp.join(".gitignore"), b"target/\n")
+ .unwrap();
+ host.write_file(&tmp.join(".git/HEAD"), b"").unwrap();
+ host.write_file(&tmp.join("target/debug/out.rs"), b"")
+ .unwrap();
+ host.write_file(&tmp.join("README.md"), b"").unwrap();
+ host.write_file(&tmp.join("src/ui/app.rs"), b"").unwrap();
+
+ // From a subdirectory: the walk is of the whole repository.
+ let FileList::Ready(idx) = walk(&*host, &[tmp.join("src")], None) else {
+ panic!("the walk failed");
+ };
+ let mut rels: Vec<_> = idx.files.iter().map(|f| f.rel.replace('\\', "/")).collect();
+ rels.sort();
+ assert_eq!(rels, ["README.md", "src/ui/app.rs"]);
+ assert!(!idx.capped);
+
+ assert!(matches!(
+ walk(&*host, std::slice::from_ref(&tmp), Some(&tmp)),
+ FileList::NoRoots
+ ));
+ let _ = host.remove(&tmp, true);
+ }
+
+ #[test]
+ fn a_long_index_is_ranked_quickly_enough_to_type_into() {
+ let paths: Vec = (0..INDEX_LIMIT)
+ .map(|i| format!("crates/c{}/src/module_{i}/file_{i}.rs", i % 97))
+ .collect();
+ let refs: Vec<&str> = paths.iter().map(String::as_str).collect();
+ let idx = index(&refs);
+ let started = Instant::now();
+ let got = rank(&idx, "file_4242", 20);
+ assert_eq!(got[0].1.name(), "file_4242.rs");
+ // Generous, so a loaded CI box does not flake; the point is that a
+ // full index is not seconds of work per keystroke.
+ assert!(
+ started.elapsed() < Duration::from_secs(2),
+ "ranking {INDEX_LIMIT} files took {:?}",
+ started.elapsed()
+ );
+ }
+}
diff --git a/src/ui/search/mod.rs b/src/ui/search/mod.rs
index 309cafb4..13508e1e 100644
--- a/src/ui/search/mod.rs
+++ b/src/ui/search/mod.rs
@@ -1,18 +1,21 @@
-//! Search Everywhere: one modal over everything the app can find — actions,
-//! terminals, hosts — each in a tab of its own, and all of them at once in
-//! the All tab.
+//! Search Everywhere: one modal over everything the app can find — files,
+//! actions, terminals, hosts — each in a tab of its own, and all of them at
+//! once in the All tab.
//!
//! - [`command`]: what a row runs ([`CommandKind`]) and the rows themselves.
//! - [`sources`]: what each tab holds and how it ranks against a query.
+//! - [`files`]: the Files tab — the project's file index and quick open.
//! - [`score`]: the one fuzzy scorer every tab shares.
//! - [`view`]: the modal — the tab row, the list, the theme picker.
mod command;
+mod files;
mod score;
mod sources;
mod view;
pub(crate) use command::{Avatar, ChromeState, CommandGroup, CommandKind, Item};
+pub(crate) use files::{FileIndexStore, FileList};
pub(crate) use score::fuzzy_score;
pub(crate) use sources::{Catalog, host_items};
pub(crate) use view::{KEY_CONTEXT, SearchEvent, SearchView};
@@ -22,6 +25,7 @@ use crate::ui::i18n::{L10nKey, t};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum SearchTab {
All,
+ Files,
Actions,
Terminals,
Sessions,
@@ -30,8 +34,12 @@ pub(crate) enum SearchTab {
impl SearchTab {
/// The tab row, left to right, and the order Tab walks it.
- pub(crate) const ORDER: [SearchTab; 5] = [
+ ///
+ /// Files right after All: it is the tab its own chord opens, and what
+ /// VS Code's ⌘P taught people to expect first.
+ pub(crate) const ORDER: [SearchTab; 6] = [
SearchTab::All,
+ SearchTab::Files,
SearchTab::Actions,
SearchTab::Terminals,
SearchTab::Sessions,
@@ -41,6 +49,7 @@ impl SearchTab {
pub(crate) fn title(self) -> &'static str {
t(match self {
SearchTab::All => L10nKey::SearchTabAll,
+ SearchTab::Files => L10nKey::SearchTabFiles,
SearchTab::Actions => L10nKey::SearchTabActions,
SearchTab::Terminals => L10nKey::SearchTabTerminals,
SearchTab::Sessions => L10nKey::SearchTabSessions,
@@ -51,6 +60,7 @@ impl SearchTab {
pub(crate) fn placeholder(self) -> &'static str {
t(match self {
SearchTab::All => L10nKey::SearchPlaceholderAll,
+ SearchTab::Files => L10nKey::SearchPlaceholderFiles,
SearchTab::Actions => L10nKey::SearchPlaceholderActions,
SearchTab::Terminals => L10nKey::SearchPlaceholderTerminals,
SearchTab::Sessions => L10nKey::SearchPlaceholderSessions,
@@ -76,7 +86,8 @@ mod tests {
#[test]
fn tab_steps_wrap_both_ways() {
- assert_eq!(SearchTab::All.step(true), SearchTab::Actions);
+ assert_eq!(SearchTab::All.step(true), SearchTab::Files);
+ assert_eq!(SearchTab::Files.step(true), SearchTab::Actions);
assert_eq!(SearchTab::Hosts.step(true), SearchTab::All);
assert_eq!(SearchTab::All.step(false), SearchTab::Hosts);
assert_eq!(SearchTab::Terminals.step(false), SearchTab::Actions);
diff --git a/src/ui/search/sources.rs b/src/ui/search/sources.rs
index af71211e..804fc1c5 100644
--- a/src/ui/search/sources.rs
+++ b/src/ui/search/sources.rs
@@ -8,6 +8,7 @@ use gpui::{App, SharedString};
use super::SearchTab;
use super::command::{CommandGroup, CommandKind, Item};
+use super::files::{FileList, Files};
use super::score::{frecency_bonus, item_score};
use crate::core::config::Config;
use crate::core::ssh_profile::parse_quick_connect;
@@ -57,6 +58,12 @@ pub(crate) trait Source {
/// typed. Empty is fine: the tab then shows only once a query finds it.
fn highlights(&self, cx: &App) -> Vec- ;
+ /// Whether the All tab shows this tab before anything is typed. Every tab
+ /// with anything in it does, unless what it would show is only filler.
+ fn on_the_empty_all_tab(&self) -> bool {
+ true
+ }
+
/// Rows answering `query`, best first, each with the score that put it
/// there. The All tab compares these scores across tabs, so every source
/// scores with [`item_score`] and only nudges it.
@@ -80,6 +87,9 @@ pub(crate) struct Catalog {
pub sessions: Vec
- ,
/// How many of `sessions` lead the list because they ran here.
pub sessions_here: usize,
+ /// The project's files, from the window's last walk of it. A fresh walk
+ /// may land after the search opens (`Tty7App::refresh_file_index`).
+ pub files: FileList,
}
impl Catalog {
@@ -98,6 +108,7 @@ impl Catalog {
hosts,
sessions: Vec::new(),
sessions_here: 0,
+ files: FileList::default(),
}
}
@@ -111,6 +122,7 @@ impl Catalog {
here: self.sessions_here,
})),
SearchTab::Hosts => Some(Box::new(Hosts(&self.hosts))),
+ SearchTab::Files => Some(Box::new(Files(&self.files))),
}
}
@@ -138,7 +150,7 @@ impl Catalog {
if query.is_empty() {
// Terminals first: before anything is typed the likeliest thing
// wanted is the tab you were just in.
- let mut sources: Vec<_> = tabs.collect();
+ let mut sources: Vec<_> = tabs.filter(|s| s.on_the_empty_all_tab()).collect();
sources.sort_by_key(|s| s.tab() != SearchTab::Terminals);
return sources
.into_iter()
@@ -681,6 +693,70 @@ mod tests {
});
}
+ fn with_files(paths: &[&str]) -> Catalog {
+ let mut catalog = Catalog::new(
+ vec![Item::new("Split Right", CommandKind::SplitRight)],
+ vec![terminal("main shell", "here")],
+ Vec::new(),
+ );
+ let root = std::path::PathBuf::from("/repo");
+ let files = paths.iter().map(|p| root.join(p)).collect();
+ catalog.files = FileList::Ready(std::sync::Arc::new(super::super::files::build_index(
+ &[root],
+ files,
+ false,
+ )));
+ catalog
+ }
+
+ /// Files join the All tab once something is typed, under their own
+ /// header, and stay out of it before — a sample of the project is not an
+ /// answer to anything.
+ #[gpui::test]
+ fn files_are_on_the_all_tab_only_once_a_query_finds_them(cx: &mut TestAppContext) {
+ with_config(cx);
+ let catalog = with_files(&["src/main.rs", "src/ui/app.rs"]);
+ cx.update(|cx| {
+ // No dot in it: `main.rs` also reads as a host name, and a typed
+ // address leads the All tab whatever else matches.
+ let sections = catalog.sections(SearchTab::All, "ui/app", cx);
+ assert_eq!(sections[0].title.as_deref(), Some("Files"));
+ assert_eq!(row_titles(§ions[0]), vec!["app.rs"]);
+
+ let empty = catalog.sections(SearchTab::All, "", cx);
+ let headers: Vec<_> = empty.iter().filter_map(|s| s.title.clone()).collect();
+ assert!(
+ !headers.iter().any(|h| h == "Files"),
+ "no files before a query: {headers:?}"
+ );
+
+ // The tab itself shows some before anything is typed.
+ let own = catalog.sections(SearchTab::Files, "", cx);
+ assert_eq!(own[0].rows.len(), 2);
+ });
+ }
+
+ /// `name:line:column` opens the file there, and says so on the row.
+ #[gpui::test]
+ fn a_file_query_with_a_line_opens_on_that_line(cx: &mut TestAppContext) {
+ with_config(cx);
+ let catalog = with_files(&["src/main.rs"]);
+ cx.update(|cx| {
+ let sections = catalog.sections(SearchTab::Files, "main.rs:120:4", cx);
+ let item = sections[0].rows[0].item().expect("a file row");
+ assert_eq!(
+ item.kind,
+ CommandKind::OpenFile {
+ path: "/repo/src/main.rs".into(),
+ line: Some(120),
+ column: Some(4),
+ }
+ );
+ assert_eq!(item.subtitle.as_deref(), Some("src"));
+ assert_eq!(item.note.as_deref(), Some("line 120"));
+ });
+ }
+
fn session(title: &str, section: &str) -> Item {
Item::new(
title,
diff --git a/src/ui/search/view.rs b/src/ui/search/view.rs
index 238454b7..4b9c55c8 100644
--- a/src/ui/search/view.rs
+++ b/src/ui/search/view.rs
@@ -12,9 +12,9 @@ use gpui_component::{
v_flex,
};
-use super::SearchTab;
use super::command::{CommandKind, Item};
use super::sources::{Catalog, Row, Section, plain};
+use super::{FileList, SearchTab};
use crate::core::actions::{SearchNextTab, SearchPrevTab};
use crate::ui::dialog::{CARD_RADIUS, FOOTER_H, KEYCAP, keycap};
use crate::ui::i18n::{L10nKey, t, t_fmt};
@@ -234,7 +234,22 @@ impl ListDelegate for SearchDelegate {
) -> impl IntoElement {
// The SSH hint only where typing user@host would actually connect. A
// theme picker with no matches teaching SSH is a crossed wire (#602).
+ let mut headline = t(L10nKey::SearchNoResults);
let hint = match self.scope {
+ // An empty Files tab is rarely "nothing matches": say which of the
+ // other reasons it is, since each has a different way out.
+ Scope::Tab(SearchTab::Files) => match &self.catalog.files {
+ FileList::NoRoots => {
+ headline = t(L10nKey::SearchFilesNoRoots);
+ t(L10nKey::SearchFilesNoRootsHint)
+ }
+ FileList::Indexing => {
+ headline = t(L10nKey::SearchFilesIndexing);
+ ""
+ }
+ FileList::Failed => t(L10nKey::SearchFilesFailed),
+ FileList::Ready(_) => t(L10nKey::PaletteTryDifferentSearch),
+ },
Scope::Tab(SearchTab::All | SearchTab::Hosts) => t(L10nKey::ConnectSshHint),
Scope::Tab(SearchTab::Sessions) if self.query.trim().is_empty() => {
t(L10nKey::SearchSessionsEmptyHint)
@@ -251,13 +266,15 @@ impl ListDelegate for SearchDelegate {
.items_center()
.text_size(rems(ROW_TEXT))
.text_color(theme.foreground)
- .child(t(L10nKey::SearchNoResults))
- .child(
- div()
- .text_size(rems(ROW_META))
- .text_color(theme.muted_foreground)
- .child(hint),
- )
+ .child(headline)
+ .when(!hint.is_empty(), |d| {
+ d.child(
+ div()
+ .text_size(rems(ROW_META))
+ .text_color(theme.muted_foreground)
+ .child(hint),
+ )
+ })
}
fn render_item(
@@ -453,9 +470,7 @@ impl SearchView {
}
/// The Sessions tab's rows, arrived from a scan that finished after the
- /// search opened. The highlight stays on the row it was on when that row
- /// is still there, so a list that fills in under the cursor does not
- /// move what Return runs.
+ /// search opened.
pub(crate) fn set_sessions(
&mut self,
sessions: Vec
- ,
@@ -464,9 +479,38 @@ impl SearchView {
cx: &mut Context
,
) {
self.sessions_landed += 1;
+ self.update_catalog(
+ |catalog| {
+ catalog.sessions = sessions;
+ catalog.sessions_here = here;
+ },
+ window,
+ cx,
+ );
+ }
+
+ /// The Files tab's list, arrived from a walk that finished after the
+ /// search opened.
+ pub(crate) fn set_files(
+ &mut self,
+ files: FileList,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.update_catalog(|catalog| catalog.files = files, window, cx);
+ }
+
+ /// Changes part of the catalog under an open list. The highlight stays on
+ /// the row it was on when that row is still there, so a list that fills in
+ /// under the cursor does not move what Return runs.
+ fn update_catalog(
+ &mut self,
+ change: impl FnOnce(&mut Catalog),
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
let mut catalog = (*self.catalog).clone();
- catalog.sessions = sessions;
- catalog.sessions_here = here;
+ change(&mut catalog);
self.catalog = Rc::new(catalog);
if self.in_sub_list() {
return;
@@ -484,6 +528,11 @@ impl SearchView {
cx.notify();
}
+ /// The tab showing — or, in a row's own list, the one Escape returns to.
+ pub(crate) fn tab(&self) -> SearchTab {
+ self.tab
+ }
+
fn step_tab(&mut self, forward: bool, window: &mut Window, cx: &mut Context) {
self.set_tab(self.tab.step(forward), None, window, cx);
}
@@ -601,6 +650,9 @@ impl SearchView {
CommandKind::SearchHosts => {
self.set_tab(SearchTab::Hosts, Some(""), window, cx)
}
+ CommandKind::QuickOpenFile => {
+ self.set_tab(SearchTab::Files, Some(""), window, cx)
+ }
kind => cx.emit(SearchEvent::Confirm(kind)),
},
None => cx.emit(SearchEvent::Dismiss),
@@ -930,7 +982,8 @@ mod tests {
vcx.run_until_parked();
let view = open(&app, &mut vcx);
- vcx.simulate_keystrokes("tab");
+ // Past Files, which comes right after All.
+ vcx.simulate_keystrokes("tab tab");
vcx.run_until_parked();
view.read_with(&vcx, |view, cx| {
assert_eq!(view.tab, SearchTab::Actions);
@@ -939,7 +992,7 @@ mod tests {
assert_eq!(first_kind(&view, &mut vcx), Some(CommandKind::SplitRight));
// Backwards, and round the end.
- vcx.simulate_keystrokes("shift-tab shift-tab");
+ vcx.simulate_keystrokes("shift-tab shift-tab shift-tab");
vcx.run_until_parked();
view.read_with(&vcx, |view, _| assert_eq!(view.tab, SearchTab::Hosts));
assert!(
@@ -999,6 +1052,81 @@ mod tests {
});
}
+ /// The quick-open chord lands on the Files tab, from nowhere or from
+ /// another tab, and puts the search away when it is already there.
+ #[gpui::test]
+ fn quick_open_goes_to_the_files_tab_and_back_out(cx: &mut TestAppContext) {
+ let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
+ crate::ui::i18n::set_locale("en");
+ app.update_in(&mut vcx, |app, window, cx| app.quick_open_file(window, cx));
+ vcx.run_until_parked();
+ let view = open(&app, &mut vcx);
+ view.read_with(&vcx, |view, _| assert_eq!(view.tab, SearchTab::Files));
+
+ view.update_in(&mut vcx, |view, window, cx| {
+ view.set_tab(SearchTab::Actions, None, window, cx)
+ });
+ app.update_in(&mut vcx, |app, window, cx| app.quick_open_file(window, cx));
+ vcx.run_until_parked();
+ view.read_with(&vcx, |view, _| assert_eq!(view.tab, SearchTab::Files));
+
+ app.update_in(&mut vcx, |app, window, cx| app.quick_open_file(window, cx));
+ vcx.run_until_parked();
+ assert!(app.read_with(&vcx, |app, _| app.search.is_none()));
+ }
+
+ /// The Actions tab's "Go to File…" row moves the search to its Files tab
+ /// rather than closing it, with whatever found the row cleared away.
+ #[gpui::test]
+ fn go_to_file_moves_to_an_empty_files_tab(cx: &mut TestAppContext) {
+ let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
+ crate::ui::i18n::set_locale("en");
+ app.update_in(&mut vcx, |app, window, cx| {
+ app.open_search(SearchTab::Actions, "go-to-file", window, cx)
+ });
+ vcx.run_until_parked();
+ let view = open(&app, &mut vcx);
+ assert_eq!(
+ first_kind(&view, &mut vcx),
+ Some(CommandKind::QuickOpenFile)
+ );
+
+ vcx.simulate_keystrokes("enter");
+ vcx.run_until_parked();
+ view.read_with(&vcx, |view, cx| {
+ assert_eq!(view.tab, SearchTab::Files);
+ assert_eq!(view.list.read(cx).delegate().query, "");
+ });
+ }
+
+ /// A walk that lands after the search opened fills the Files tab in place.
+ #[gpui::test]
+ fn a_file_list_that_lands_late_fills_the_tab(cx: &mut TestAppContext) {
+ let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
+ crate::ui::i18n::set_locale("en");
+ app.update_in(&mut vcx, |app, window, cx| {
+ app.open_search(SearchTab::Files, "lib", window, cx)
+ });
+ vcx.run_until_parked();
+ let view = open(&app, &mut vcx);
+ assert_eq!(first_kind(&view, &mut vcx), None);
+
+ let root = std::path::PathBuf::from("/repo");
+ let index = super::super::files::build_index(
+ std::slice::from_ref(&root),
+ vec![root.join("src/lib.rs")],
+ false,
+ );
+ view.update_in(&mut vcx, |view, window, cx| {
+ view.set_files(FileList::Ready(std::sync::Arc::new(index)), window, cx)
+ });
+ vcx.run_until_parked();
+ assert!(matches!(
+ first_kind(&view, &mut vcx),
+ Some(CommandKind::OpenFile { .. })
+ ));
+ }
+
fn past_session(id: &str) -> Item {
Item::new(
format!("session {id}"),
diff --git a/src/ui/theme.rs b/src/ui/theme.rs
index 6dff84ae..c9834ff1 100644
--- a/src/ui/theme.rs
+++ b/src/ui/theme.rs
@@ -85,6 +85,7 @@ pub(crate) fn set_menus(cx: &mut App) {
]),
Menu::new(t(L10nKey::AppMenuView)).items([
MenuItem::action(t(L10nKey::AppMenuSearchEverywhere), TogglePalette),
+ MenuItem::action(t(L10nKey::CmdGoToFile), QuickOpenFile),
MenuItem::separator(),
MenuItem::action(t(L10nKey::AppMenuIncreaseFontSize), IncreaseFontSize),
MenuItem::action(t(L10nKey::AppMenuDecreaseFontSize), DecreaseFontSize),