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),