use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; use gpui::prelude::*; use gpui::{ AnyElement, Context, Entity, EntityInputHandler as _, Focusable as _, MouseButton, PromptLevel, SharedString, Subscription, Window, div, px, rems, }; use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::input::{Input, InputEvent, InputState, Position, RopeExt as _, TabSize}; use gpui_component::menu::{ContextMenuExt as _, PopupMenu, PopupMenuItem}; 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}; mod gutter; mod nav; pub(crate) mod outline; mod problems; mod split; pub(crate) use nav::KEY_CONTEXT as NAV_KEY_CONTEXT; 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 /// file — and saving goes straight through this handle, so a buffer stays /// saveable however the window's own machine has changed underneath it. 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, saving: bool, save_pending: bool, save_then_close: bool, reload_seq: u64, pub(crate) conflict: Option, pub(crate) preview: bool, pub(crate) wrap: bool, /// The rendered-Markdown pane's own scroll. Per file, so switching away /// and back lands where you were reading — and so the pane can carry the /// scrollbar every other scrolling surface in tty7 has. The editor itself /// gets one from `Input`. pub(crate) preview_scroll: gpui::ScrollHandle, /// Git change markers — see `gutter`. gutter: gutter::BufferGutter, _sub: Subscription, _observe: Subscription, } 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) } fn local(&self) -> Option { (self.untitled.is_none() && self.host.id().is_local()).then(|| LocalFile { id: self.id(), input: self.input.clone(), path: self.path.clone(), language: self.language(), indent: self.indent, }) } } /// A saved file on this machine, as the language servers see it. pub(crate) struct LocalFile { pub(crate) id: BufferId, pub(crate) input: Entity, pub(crate) path: PathBuf, pub(crate) language: &'static str, pub(crate) indent: Indent, } /// 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) active: usize, pub(crate) roots: Vec, pub(crate) expanded: std::collections::HashSet, pub(crate) selected: Option, /// The second editor group, when the editor is split — the one without /// the focus. `files` and `active` above are always the focused group's /// (see `split`). pub(crate) split: Option, } impl TabCode { pub(crate) fn new() -> Self { Self { visible: false, files: Vec::new(), active: 0, roots: Vec::new(), expanded: std::collections::HashSet::new(), selected: None, split: None, } } pub(crate) fn active_id(&self) -> Option { self.files.get(self.active).copied() } /// 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, Debug)] pub(crate) enum AfterUnsaved { CloseTab(TabId), /// Take these files out of that tab's strip — Close Others and Close to /// the Right. CloseFiles(TabId, Vec), 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, }, /// A new name for the symbol at `offset` (`ui::lsp`). Rename { id: BufferId, offset: usize, }, } /// 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, watch_busy: bool, watch_dirty: bool, watched_dirs: HashSet, watched_files: HashSet, events_tx: smol::channel::Sender>, /// Back/forward history, outlines and Go to Symbol's preview. nav: nav::EditorNav, /// The Problems list at the foot of the panel — see `problems`. problems: problems::ProblemsPane, } impl EditorPanelState { pub(crate) fn new(window: &mut Window, cx: &mut Context) -> Self { let (tx, rx) = smol::channel::unbounded::>(); cx.spawn_in(window, async move |app, cx| { while let Ok(first) = rx.recv().await { cx.background_executor().timer(RELOAD_DEBOUNCE).await; let mut changed: HashSet = first.into_iter().collect(); while let Ok(more) = rx.try_recv() { changed.extend(more); } let ok = app.update_in(cx, |app, window, cx| { for path in changed { if app.editor.watched_files.contains(&path) { app.editor_handle_external_change(&path, window, cx); } } }); if ok.is_err() { break; } } }) .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, watch_busy: false, watch_dirty: false, watched_dirs: HashSet::new(), watched_files: HashSet::new(), events_tx: tx, nav: nav::EditorNav::new(cx), problems: Default::default(), } } } pub(crate) fn language_for_path(path: &Path) -> &'static str { if let Some(name) = path.file_name().and_then(|n| n.to_str()) { let lowered = name.to_ascii_lowercase(); match lowered.as_str() { "makefile" | "gnumakefile" => return "make", "cmakelists.txt" => return "cmake", _ => {} } if lowered.starts_with('.') && (lowered.contains("shrc") || lowered.ends_with("profile")) { return "bash"; } } let Some(ext) = path.extension().and_then(|e| e.to_str()) else { return "text"; }; match ext.to_ascii_lowercase().as_str() { "rs" => "rust", "go" => "go", "py" | "pyi" => "python", "js" | "mjs" | "cjs" | "jsx" => "javascript", "ts" | "mts" | "cts" => "typescript", "tsx" => "tsx", "json" | "jsonc" => "json", "toml" => "toml", "yaml" | "yml" => "yaml", "html" | "htm" => "html", "css" => "css", "md" | "markdown" => "markdown", "sh" | "bash" | "zsh" => "bash", "c" | "h" => "c", "cpp" | "cc" | "cxx" | "hpp" | "hh" => "cpp", "java" => "java", "kt" | "kts" => "kotlin", "lua" => "lua", "rb" => "ruby", "php" => "php", "sql" => "sql", "swift" => "swift", "scala" => "scala", "zig" => "zig", "proto" => "proto", "diff" | "patch" => "diff", "ex" | "exs" => "elixir", "erb" => "erb", "ejs" => "ejs", "svelte" => "svelte", "astro" => "astro", "graphql" | "gql" => "graphql", "cs" => "csharp", "cmake" | "mk" => "cmake", _ => "text", } } /// How many frames a jump-to-line may wait for the editor to be laid out. /// /// `InputState::scroll_to` gives up when the buffer has never been painted, /// and that is exactly the state a file that just opened is in: the cursor /// lands on the right line and the view stays at the top of the file, which /// is the one thing a `:120` link exists to avoid. Three frames is the same /// bounded-retry shape `prefill::select_all_when_drawn` uses against the same /// class of problem. const CURSOR_SCROLL_ATTEMPTS: u8 = 3; /// Puts the cursor at `position`, re-trying on later frames until the scroll /// that follows it can actually be computed. /// /// Applied immediately as well as on the retry: the cursor itself lands with /// no layout, so the position is right even for a pane that never paints. fn place_cursor( input: Entity, position: Position, left: u8, window: &mut Window, cx: &mut gpui::App, ) { input.update(cx, |state, cx| { state.set_cursor_position(position, window, cx); }); // A target near the top of the file scrolls nowhere and is already done; // so is one that has landed. Either way this stops. if left <= 1 || input.read(cx).scroll_offset().y != px(0.) { return; } window.on_next_frame(move |window, cx| { place_cursor(input, position, left - 1, window, cx); }); // Registering a callback does not by itself ask for a frame, and an // overlay that has finished drawing has no other reason to produce one. 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. NotText(PathBuf), /// Something already worded for the user. Message(String), } /// 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. /// /// The execute bit is what `open` reads to decide between displaying a file /// and launching it; Windows has no such bit, so there the extension is the /// only thing that says so. fn is_program(path: &Path) -> bool { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; return std::fs::metadata(path) .is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0); } #[cfg(not(unix))] { let Some(ext) = path.extension().and_then(|e| e.to_str()) else { return false; }; matches!( ext.to_ascii_lowercase().as_str(), "exe" | "com" | "bat" | "cmd" | "scr" | "pif" | "msi" | "ps1" | "vbs" | "js" | "jse" | "wsf" | "wsh" | "cpl" | "msc" | "hta" | "reg" | "lnk" ) } } /// A file a browser renders rather than showing its source. fn opens_in_browser(path: &Path) -> bool { path.extension().and_then(|e| e.to_str()).is_some_and(|e| { matches!( e.to_ascii_lowercase().as_str(), "html" | "htm" | "xhtml" | "svg" ) }) } /// Opens a local file in the default web browser. On macOS that is asked /// for by name: what `.html` is associated with is as often an editor. The /// other desktops go by the association. fn open_in_browser(path: &Path) -> std::io::Result<()> { #[cfg(target_os = "macos")] if let Some(bundle) = default_browser_bundle_id() { std::process::Command::new("open") .arg("-b") .arg(bundle) .arg(path) .spawn()?; return Ok(()); } crate::terminal::view::open_file_path(path) } /// The app that handles `https:` links — the default browser. #[cfg(target_os = "macos")] fn default_browser_bundle_id() -> Option { use core_foundation::base::TCFType as _; use core_foundation::string::{CFString, CFStringRef}; #[link(name = "CoreServices", kind = "framework")] unsafe extern "C" { fn LSCopyDefaultHandlerForURLScheme(scheme: CFStringRef) -> CFStringRef; } let scheme = CFString::new("https"); let handler = unsafe { LSCopyDefaultHandlerForURLScheme(scheme.as_concrete_TypeRef()) }; // A Copy function: the string is ours to release, which the wrapper does. (!handler.is_null()).then(|| unsafe { CFString::wrap_under_create_rule(handler) }.to_string()) } /// The lines a selection covers, 1-based and inclusive. One that ends at the /// very start of a line — a whole-line selection — does not take that line. fn selected_lines( text: &gpui_component::input::Rope, range: std::ops::Range, ) -> Option<(usize, usize)> { if range.is_empty() { return None; } let start = text.offset_to_point(range.start); let end = text.offset_to_point(range.end); let last = match end.column == 0 && end.row > start.row { true => end.row - 1, false => end.row, }; Some((start.row + 1, last + 1)) } /// 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, } fn classify_external_change( saving: bool, dirty: bool, disk_mtime: 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; } if dirty { ExternalChange::Conflict } else { ExternalChange::Reload } } /// 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), } /// 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), } } impl Tty7App { pub(crate) fn tab_code(&self) -> Option<&TabCode> { self.tabs.get(self.active)?.code.as_deref() } pub(crate) fn tab_code_mut(&mut self) -> Option<&mut TabCode> { self.tabs.get_mut(self.active)?.code.as_deref_mut() } pub(crate) fn tab_code_mut_or_init(&mut self) -> Option<&mut TabCode> { let tab = self.tabs.get_mut(self.active)?; Some(tab.code.get_or_insert_with(|| Box::new(TabCode::new()))) } pub(crate) fn code_panel_visible(&self) -> bool { 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 places show this buffer: every tab showing it, twice for a /// tab whose split shows it in both groups. fn buffer_refs(&self, id: BufferId) -> usize { self.tabs .iter() .filter_map(|t| t.code.as_deref()) .map(|c| c.views_of(id)) .sum() } /// How many tabs show this buffer, in either group. fn buffer_tabs(&self, id: BufferId) -> usize { self.tabs .iter() .filter_map(|t| t.code.as_deref()) .filter(|c| c.shows(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.all_files() .into_iter() .filter(|id| self.buffer(*id).is_some_and(|b| b.dirty) && self.buffer_tabs(*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. Those are checked when they are // saved instead: see `write_checked`. let watch_host = self.spawn_host(cx); let files: HashSet = self .editor .buffers .iter() .filter(|f| f.untitled.is_none() && f.host.id() == watch_host) .map(|f| f.path.clone()) .collect(); let dirs: HashSet = files .iter() .filter_map(|p| p.parent().map(Path::to_path_buf)) .collect(); self.editor.watched_files = files; // Every change to which files are open, or where they live, passes // through here — which is exactly what the language servers follow. self.lsp_sync_buffers(cx); if dirs == self.editor.watched_dirs { return; } self.editor.watched_dirs = dirs; self.editor_watch_apply(cx); } fn editor_watch_apply(&mut self, cx: &mut Context) { let want: Vec = self.editor.watched_dirs.iter().cloned().collect(); let Some(host) = self.active_host(cx) else { return; }; if !self .editor .watch_host .as_ref() .is_some_and(|opened_with| Arc::ptr_eq(opened_with, &host)) { self.editor.watch = None; self.editor.watch_host = None; self.editor.watch_busy = false; self.editor.watch_dirty = false; } if let Some(sub) = self.editor.watch.clone() { if self.editor.watch_busy { self.editor.watch_dirty = true; return; } self.editor.watch_busy = true; HostOps::run( host, cx, move |_| sub.set_dirs(&want), |app: &mut Self, result: std::io::Result<()>, cx| { app.editor.watch_busy = false; if let Err(e) = result { log::warn!("editor: could not update the watched set: {e}"); } if std::mem::take(&mut app.editor.watch_dirty) { app.editor_watch_apply(cx); } }, ); return; } if self.editor.watch_opening { return; } self.editor.watch_opening = true; let opened_host = Arc::clone(&host); let opened_with = self.editor.watched_dirs.clone(); HostOps::run( host, cx, { let want = want.clone(); move |h| h.watch(&want).map(Arc::new) }, move |app, result: std::io::Result>, cx| { app.editor.watch_opening = false; let sub = match result { Ok(sub) => sub, Err(e) => { log::warn!("editor: external-change watcher unavailable: {e}"); return; } }; let events = sub.events().clone(); app.editor.watch = Some(sub); app.editor.watch_host = Some(opened_host); cx.spawn(async move |app, cx| { while let Ok(batch) = events.recv().await { let ok = app.update(cx, |app, _cx| { let _ = app.editor.events_tx.try_send(batch); }); if ok.is_err() { break; } } }) .detach(); if app.editor.watched_dirs != opened_with { app.editor_watch_apply(cx); } }, ); } /// Shows what a file link in the grid pointed at. /// /// A file opens in the editor, on the line the link named; the tree /// follows along so "what does it say?" and "where does it live?" are /// answered by the same click. A directory has no contents to show, so it /// is only ever the tree. pub(crate) fn open_linked_file( &mut self, path: &Path, line: Option, column: Option, is_dir: bool, window: &mut Window, cx: &mut Context, ) { if is_dir { self.file_tree_show(path, window, cx); return; } self.open_file_in_editor_at(path, line, column, window, cx); self.file_tree_reveal_path(path, cx); } /// [`Self::open_file_in_editor`], landing the cursor on a line the caller /// already knows — what a `src/main.rs:120:3` in the grid was pointing at. pub(crate) fn open_file_in_editor_at( &mut self, path: &Path, line: Option, column: Option, window: &mut Window, cx: &mut Context, ) { self.editor.pending_cursor = line.map(|line| (path.to_path_buf(), line, column.unwrap_or(1))); self.open_file_in_editor(path, window, cx); } /// Moves the cursor to the position a link asked for, if the file it asked /// about is the one that just opened. Anything else — a different file /// opened in between, a file that never arrived — drops the request rather /// than throwing the cursor somewhere it was never meant to go. fn apply_pending_cursor( &mut self, id: BufferId, requested: &Path, window: &mut Window, cx: &mut Context, ) { // Peeked before it is taken: another file opening in the meantime must // not swallow a request that was never about it. if self .editor .pending_cursor .as_ref() .is_none_or(|(wanted, ..)| wanted != requested) { return; } let Some((_, line, column)) = self.editor.pending_cursor.take() else { return; }; let Some(file) = self.buffer_mut(id) else { return; }; // A line to land on is a place in the source. A Markdown file that // would otherwise open rendered (the remembered preview) opens as // text here, or the cursor would be placed somewhere nobody can see. file.preview = false; let input = file.input.clone(); // The grid counts from one and `Position` counts from zero, and a // compiler that says "line 1" means the first line either way. let position = Position { line: line.saturating_sub(1), character: column.saturating_sub(1), }; place_cursor(input, position, CURSOR_SCROLL_ATTEMPTS, window, cx); } pub(crate) fn open_file_in_editor( &mut self, path: &Path, window: &mut Window, cx: &mut Context, ) { let Some(host) = self.active_host(cx) else { return; }; self.editor_open_on_host(host, path, window, cx); } /// [`Self::open_file_in_editor`] against an explicit host — the SFTP /// browser's files live on a host that is never the active one. pub(crate) fn editor_open_on_host( &mut self, host: SharedHost, path: &Path, window: &mut Window, cx: &mut Context, ) { let Some(tab) = self.tabs.get(self.active).map(|t| t.tree_id.get()) else { return; }; self.raise_code_overlay(); 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 p = path.to_path_buf(); let requested = p.clone(); let host_id = host.id(); HostOps::run_in( host.clone(), window, cx, move |h| load_file(h, p), move |app, opened, window, cx| match opened { 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); } Err(EditorOpenError::Message(message)) => { window.push_notification(message, cx); } }, ); } /// What to do with a file the built-in editor cannot show. /// /// A click on a PNG or a `.zip` meant "open this", not "tell me it is not /// text", and on this machine the desktop knows how. A file on another /// machine has nobody here to hand it to, so that one gets the words. /// /// A program does not: `open` on a Mach-O binary runs it, and a build's /// output is full of paths to programs. Clicking a word in a terminal /// must not be a way to execute one, so those keep the words too. fn open_outside_the_editor( &mut self, host_id: HostId, path: &Path, window: &mut Window, cx: &mut Context, ) { if !host_id.is_local() || !self.can_spawn_locally(cx) || is_program(path) { window.push_notification( t_fmt( L10nKey::EditorBinaryFile, &[("path", &path.display().to_string())], ), cx, ); return; } Self::open_with(path, crate::terminal::view::open_file_path, window, cx); } /// Runs one of the desktop openers on a local file, and says so when it /// fails to spawn — as any opener can (#542). fn open_with( path: &Path, opener: fn(&Path) -> std::io::Result<()>, window: &mut Window, cx: &mut Context, ) { if let Err(e) = opener(path) { log::warn!("failed to open {}: {e}", path.display()); window.push_notification( crate::ui::host_ops::failure( t_fmt( L10nKey::LinkFileOpenFailed, &[ ("path", &path.display().to_string()), ("error", &e.to_string()), ], ), &e, ), cx, ); } } /// 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, tab: TabId, id: BufferId, front: bool, window: &mut Window, cx: &mut Context, ) { 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; } code.show(id); code.visible = true; if tab_ix == self.active { self.editor.bar = None; self.focus_editor(window, cx); } 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::(); ( cfg.editor_soft_wrap, 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(tab_size(indent)) .line_number(true) .searchable(true) .replaceable(true) .folding(true) .soft_wrap(wrap) // Ours is drawn by `editor_body_menu`, like every other menu // in the window; the built-in one is a native OS menu. .context_menu(false) .default_value(text) }); let id = input.entity_id(); let git_gutter = gutter::BufferGutter::attach(&input, id, cx); let sub = cx.subscribe_in( &input, window, move |this: &mut Tty7App, _input, ev, _window, cx| { if matches!(ev, InputEvent::Change) { this.editor_note_edit(id, 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(), gutter: git_gutter, _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; } self.lsp_buffer_edited(id, cx); self.editor_gutter_note_edit(id, cx); self.editor_nav_note_edit(id, cx); cx.notify(); } /// Saved files on this machine — what a language server can read /// (`ui::lsp`). Untitled buffers and files on other hosts are not. pub(crate) fn editor_local_files(&self) -> Vec { self.editor .buffers .iter() .filter_map(OpenFile::local) .collect() } pub(crate) fn editor_local_file(&self, id: BufferId) -> Option { self.buffer(id).and_then(OpenFile::local) } pub(crate) fn editor_active_local_file(&self) -> Option { self.active_buffer().and_then(OpenFile::local) } /// Asks for a new name for the symbol at `offset`, in the bar above the /// text; `ui::lsp` does the renaming once it is answered. pub(crate) fn editor_open_rename_bar( &mut self, id: BufferId, offset: usize, current: String, window: &mut Window, cx: &mut Context, ) { self.editor_open_bar(BarKind::Rename { id, offset }, current, window, cx); } /// 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; }; let buried = tab.overlay_top == crate::ui::app::OverlayTop::Diff && tab.diff_overlay.is_some() && tab.code.as_ref().is_some_and(|c| c.visible); tab.overlay_top = crate::ui::app::OverlayTop::Code; if buried { self.focus_editor(window, cx); cx.notify(); return; } let Some(tab) = self.tabs.get_mut(self.active) else { return; }; 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(); return; } code.visible = true; self.file_tree_refresh_roots(window, cx); 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 // tree" and hands the tree the focus — but nothing was putting the // tree on screen, so ⌘⇧E on a fresh tab opened an empty editor // pointing at a panel the reader could not see or reach from // there. Reveal it, then focus it. if !self.file_tree_on_screen(cx) { self.set_right_panel_tab(crate::core::config::RightPanelTab::Files, cx); } self.file_tree.focus_handle.focus(window, cx); } cx.notify(); } fn raise_code_overlay(&mut self) { if let Some(tab) = self.tabs.get_mut(self.active) { tab.overlay_top = crate::ui::app::OverlayTop::Code; } } fn focus_editor(&self, window: &mut Window, cx: &mut Context) { 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 /// rendered form to switch to. The new state is remembered for the next /// Markdown file opened. pub(crate) fn toggle_document_preview(&mut self, cx: &mut Context) { if !self.code_panel_visible() { return; } let Some(id) = self.tab_code().and_then(TabCode::active_id) else { return; }; let Some(f) = self.buffer_mut(id) else { return; }; if f.language() != "markdown" { return; } f.preview = !f.preview; let preview = f.preview; self.update_config(cx, |cfg| cfg.editor_markdown_preview = preview); cx.notify(); } /// The status bar's Wrap button, and `ToggleDocumentWrap`. Same reach as /// [`Self::toggle_document_preview`], for any file; the new state becomes /// what the next file opens with. pub(crate) fn toggle_document_wrap(&mut self, window: &mut Window, cx: &mut Context) { if !self.code_panel_visible() { return; } 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; let wrap = f.wrap; f.input.clone().update(cx, |st, cx| { st.set_soft_wrap(wrap, window, cx); }); self.update_config(cx, |cfg| cfg.editor_soft_wrap = wrap); 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); } /// Run one of gpui-component's editing actions on the active buffer, as /// if it had been pressed there. Returns false with no buffer to run it /// on. The palette closes before its row runs, so this focuses the /// buffer first rather than relying on where the focus happens to be. pub(crate) fn editor_dispatch( &mut self, action: &dyn gpui::Action, window: &mut Window, cx: &mut Context, ) -> bool { if !self.code_panel_visible() { return false; } let Some(input) = self.active_buffer().map(|f| f.input.clone()) else { return false; }; let handle = input.read(cx).focus_handle(cx); window.focus(&handle, cx); handle.dispatch_action(action, window, cx); true } /// The window's listeners for the editor's text commands, when the user /// has bound one: each runs on the editor only while it has the focus. pub(crate) fn with_editor_text_commands( root: gpui::Stateful, cx: &mut Context, ) -> gpui::Stateful { use crate::core::actions::{ EditorJoinLines, EditorRemoveSurroundingBrackets, EditorTransformLowercase, EditorTransformTitleCase, EditorTransformUppercase, EditorTrimTrailingWhitespace, }; root.on_action( cx.listener(|this, _: &EditorTransformUppercase, window, cx| { if !this.editor_has_focus(window, cx) { cx.propagate(); return; } this.editor_dispatch(&gpui_component::input::TransformToUppercase, window, cx); }), ) .on_action( cx.listener(|this, _: &EditorTransformLowercase, window, cx| { if !this.editor_has_focus(window, cx) { cx.propagate(); return; } this.editor_dispatch(&gpui_component::input::TransformToLowercase, window, cx); }), ) .on_action( cx.listener(|this, _: &EditorTransformTitleCase, window, cx| { if !this.editor_has_focus(window, cx) { cx.propagate(); return; } this.editor_dispatch(&gpui_component::input::TransformToTitleCase, window, cx); }), ) .on_action( cx.listener(|this, _: &EditorTrimTrailingWhitespace, window, cx| { if !this.editor_has_focus(window, cx) { cx.propagate(); return; } this.editor_dispatch(&gpui_component::input::TrimTrailingWhitespace, window, cx); }), ) .on_action(cx.listener(|this, _: &EditorJoinLines, window, cx| { if !this.editor_has_focus(window, cx) { cx.propagate(); return; } this.editor_dispatch(&gpui_component::input::JoinLines, window, cx); })) .on_action( cx.listener(|this, _: &EditorRemoveSurroundingBrackets, window, cx| { if !this.editor_has_focus(window, cx) { cx.propagate(); return; } this.editor_dispatch( &gpui_component::input::RemoveSurroundingBrackets, window, cx, ); }), ) } /// Whether an editor exists to run [`Self::editor_dispatch`] on. pub(crate) fn editor_can_dispatch(&self) -> bool { self.code_panel_visible() && self.active_buffer().is_some() } pub(crate) fn editor_has_focus(&self, window: &Window, cx: &Context) -> bool { self.code_panel_visible() && 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(TabCode::active_id) else { return; }; 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: BufferId, then_close: bool, force: bool, window: &mut Window, cx: &mut Context, ) { let Some(f) = self.buffer_mut(id) else { return; }; if f.untitled.is_some() { self.editor_save_as(id, then_close, window, cx); return; } f.save_then_close |= then_close; if f.saving { f.save_pending = true; return; } 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(Path::to_path_buf); HostOps::run_in( host, window, cx, 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 = 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); app.lsp_buffer_saved(id, cx); app.editor_gutter_refetch(id); // 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); } 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 context = t_fmt(L10nKey::EditorSaveFailed, &[("name", &f.label())]); HostOps::notify_err(window, cx, &context, &e); app.editor_saves_failed(id); } } cx.notify(); }, ); cx.notify(); } /// 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(); } /// Asked when the text holds a character the file's encoding cannot. fn editor_offer_utf8( &mut self, id: BufferId, then_close: bool, unmappable: char, window: &mut Window, cx: &mut Context, ) { let Some(f) = self.buffer_mut(id) else { return; }; 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(), BarKind::Rename { .. } => t_fmt(L10nKey::LspRenamePlaceholder, &[("name", &initial)]), }; 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 | BarKind::Rename { .. }) ) { 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::Rename { id, offset } => { let name = text.trim().to_string(); if name.is_empty() { return; } self.editor_close_bar(window, cx); self.lsp_rename(id, offset, name, 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(); let answer = window.prompt( PromptLevel::Warning, &t_fmt(L10nKey::EditorUnsavedChanges, &[("name", &name)]), None, // Cancel sits between Save and Discard on purpose. The platform // renders the first button as the default and lays the rest out // beside it, so Discard was landing directly next to the key that // Return presses. Apple separates them for exactly this reason. // Three answers, so the shared helper does not fit: Save keeps // index 0 (rightmost, Return), Cancel takes Escape, and Discard // sits on the far left where nothing lands by reflex. &[ gpui::PromptButton::ok(t(L10nKey::Save)), gpui::PromptButton::cancel(t(L10nKey::Cancel)), gpui::PromptButton::ok(t(L10nKey::EditorDiscard)), ], cx, ); 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, false, window, cx), 2 => app.editor_drop_buffer(id, cx), _ => {} }); }) .detach(); } /// Closes the file with this buffer in the front tab's strip, wherever /// it has moved to since a menu was built for it. fn editor_close_buffer(&mut self, id: BufferId, window: &mut Window, cx: &mut Context) { if let Some(pos) = self .tab_code() .and_then(|c| c.files.iter().position(|f| *f == id)) { self.editor_close_file(pos, window, cx); } } /// Closes several files in the front tab's strip, asking once about the /// ones whose edits would be lost with them — not those another tab still /// shows. fn editor_close_buffers( &mut self, ids: Vec, window: &mut Window, cx: &mut Context, ) { let tab_ix = self.active; let Some(tab) = self.tabs.get(tab_ix).map(|t| t.tree_id.get()) else { return; }; let unsaved: Vec = ids .iter() .copied() .filter(|id| self.buffer(*id).is_some_and(|f| f.dirty) && self.buffer_refs(*id) == 1) .collect(); if self.editor_guard_unsaved( unsaved, AfterUnsaved::CloseFiles(tab, ids.clone()), window, cx, ) { return; } for id in ids { self.editor_remove_from_tab(tab_ix, id, cx); } } /// Types `@path` into the running agent's prompt, with the selected /// lines after it when there are any. fn editor_attach_to_agent(&mut self, id: BufferId, cx: &mut Context) { let Some(f) = self.buffer(id).filter(|f| f.untitled.is_none()) else { return; }; let state = f.input.read(cx); let suffix = match selected_lines(state.text(), state.selected_range()) { Some((a, b)) if a == b => format!("#L{a}"), Some((a, b)) => format!("#L{a}-{b}"), None => String::new(), }; let path = f.path.clone(); self.attach_path_to_agent(&path, &suffix, cx); } /// Right-click in the text. Edits dispatch to the editor itself, so each /// row shows the chord that does the same thing. fn editor_body_menu( menu: PopupMenu, app: &gpui::WeakEntity, id: BufferId, cx: &gpui::App, ) -> PopupMenu { use gpui_component::input as edit; let Some(this) = app.upgrade() else { return menu; }; let this = this.read(cx); let Some(f) = this.buffer(id) else { return menu; }; let state = f.input.read(cx); let selected = !state.selected_range().is_empty(); let mut menu = menu.min_w(px(220.)).action_context(state.focus_handle(cx)); if f.untitled.is_none() { menu = menu .item( PopupMenuItem::new(t(L10nKey::FileTreeContextAttachAgent)).on_click({ let app = app.clone(); move |_, _window, cx| { let _ = app.update(cx, |this, cx| this.editor_attach_to_agent(id, cx)); } }), ) .separator(); } let menu = menu .menu(t(L10nKey::AppMenuUndo), Box::new(edit::Undo)) .menu(t(L10nKey::AppMenuRedo), Box::new(edit::Redo)) .separator() .menu_with_disabled(t(L10nKey::AppMenuCut), Box::new(edit::Cut), !selected) .menu_with_disabled(t(L10nKey::AppMenuCopy), Box::new(edit::Copy), !selected) .menu_with_disabled( t(L10nKey::AppMenuPaste), Box::new(edit::Paste), cx.read_from_clipboard().is_none(), ) .menu(t(L10nKey::AppMenuSelectAll), Box::new(edit::SelectAll)) .separator() // The line commands act on every line a cursor or selection // touches. Their chords live on gpui-component's `CodeEditor` key // context and are re-added in `keymap::fixed_bindings`, so they // beat the app's own keys while the editor has focus. .menu( t(L10nKey::EditorToggleComment), Box::new(edit::ToggleLineComment), ) .menu(t(L10nKey::EditorMoveLineUp), Box::new(edit::MoveLineUp)) .menu(t(L10nKey::EditorMoveLineDown), Box::new(edit::MoveLineDown)) .menu( t(L10nKey::EditorDuplicateLine), Box::new(edit::CopyLineDown), ) .menu(t(L10nKey::EditorDeleteLine), Box::new(edit::DeleteLine)) .separator() .menu(t(L10nKey::AppMenuFind), Box::new(edit::Search)) .menu( t(L10nKey::EditorGoToLineAction), Box::new(crate::core::actions::EditorGoToLine), ) .menu_with_disabled( t(L10nKey::EditorGitRevertChange), Box::new(crate::core::actions::EditorRevertChange), !this.editor_gutter_can_revert(id, cx), ) .menu( t(L10nKey::EditorGoToMatchingBracket), Box::new(edit::MoveToMatchingBracket), ); let menu = this.lsp_menu_items(menu, id, cx); this.editor_file_menu_items(menu, id, app, cx) } /// Right-click on a file in the header's strip. fn editor_tab_menu( menu: PopupMenu, app: &gpui::WeakEntity, pos: usize, cx: &gpui::App, ) -> PopupMenu { let Some(this) = app.upgrade() else { return menu; }; let this = this.read(cx); let Some(files) = this.tab_code().map(|c| c.files.clone()) else { return menu; }; let Some(&id) = files.get(pos) else { return menu; }; let others: Vec = files.iter().copied().filter(|f| *f != id).collect(); let right = files[pos + 1..].to_vec(); let close = |label: L10nKey, ids: Vec| { let app = app.clone(); PopupMenuItem::new(t(label)) .disabled(ids.is_empty()) .on_click(move |_, window, cx| { let ids = ids.clone(); let _ = app.update(cx, |this, cx| this.editor_close_buffers(ids, window, cx)); }) }; let menu = menu .min_w(px(220.)) .item( PopupMenuItem::new(t(L10nKey::TabContextCloseTab)).on_click({ let app = app.clone(); move |_, window, cx| { let _ = app.update(cx, |this, cx| this.editor_close_buffer(id, window, cx)); } }), ) .item(close(L10nKey::AppMenuCloseOtherTabs, others)) .item(close(L10nKey::AppMenuCloseTabsRight, right)); this.editor_file_menu_items(menu, id, app, cx) } /// What both menus offer for the file as a whole: open it outside tty7, /// show it in its folder, copy where it is. Nothing for a file that has /// never been saved, since it is not anywhere yet. fn editor_file_menu_items( &self, menu: PopupMenu, id: BufferId, app: &gpui::WeakEntity, cx: &gpui::App, ) -> PopupMenu { let Some(f) = self.buffer(id).filter(|f| f.untitled.is_none()) else { return menu; }; let path = f.path.clone(); // Only this machine's desktop can open or show a file, and only a // file that is on this machine — the same rule as the file tree. let local = f.host.id().is_local(); let mut menu = menu.separator(); if local && self.can_spawn_locally(cx) && !is_program(&path) { let browser = opens_in_browser(&path); let label = match browser { true => t(L10nKey::PanelOpenInBrowser), false => t(L10nKey::AppMenuOpenLinkWithDefaultApp), }; let opener: fn(&Path) -> std::io::Result<()> = match browser { true => open_in_browser, false => crate::terminal::view::open_file_path, }; menu = menu.item(PopupMenuItem::new(label).on_click({ let app = app.clone(); let path = path.clone(); move |_, window, cx| { let _ = app.update(cx, |_, cx| Self::open_with(&path, opener, window, cx)); } })); } if local { menu = menu.item( PopupMenuItem::new(crate::ui::right_panel::reveal_label()).on_click({ let path = path.clone(); move |_, _window, cx| { cx.reveal_path(&crate::ui::path_display::native_separators(&path)); } }), ); } let copy = |label: L10nKey, text: String| { PopupMenuItem::new(t(label)).on_click(move |_, _window, cx| { cx.write_to_clipboard(gpui::ClipboardItem::new_string(text.clone())); }) }; // A remote host's paths are already spelled its own way; only one on // this machine is re-spelled with its separators. let full = match local { true => crate::ui::path_display::native_separators(&path) .display() .to_string(), false => path.display().to_string(), }; menu = menu.item(copy(L10nKey::FileTreeContextCopyPath, full)); if let Some(rel) = self.path_under_tree_root(&path) { let rel = match local { true => crate::ui::path_display::native_separators(&rel) .display() .to_string(), false => rel.display().to_string(), }; menu = menu.item(copy(L10nKey::EditorCopyRelativePath, rel)); } menu } pub(crate) fn editor_close_active_if_focused( &mut self, window: &mut Window, cx: &mut Context, ) -> bool { if !self.editor_panel_has_focus(window, cx) { return false; } let Some(code) = self.tab_code_mut() else { return false; }; if code.files.is_empty() { code.visible = false; cx.notify(); return true; } let active = code.active; self.editor_close_file(active, window, cx); true } /// 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()) { code.close_in_focused(id); } 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_everywhere(id); } self.editor.buffers.retain(|b| b.id() != id); self.editor.nav.forget_buffer(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.shows(*id))) }) && tab_ix == self.active && let Some(code) = self.tab_code_mut() && let Some(pos) = { if !code.files.contains(&ids[0]) { code.swap_focus(); } 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::CloseFiles(tab, ids) => { if let Some(ix) = self.tab_index_of(tab) { for id in ids { self.editor_remove_from_tab(ix, id, 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, ) { // 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(); let p = path.to_path_buf(); let landed = p.clone(); HostOps::run_in( host, 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) }, ); } fn editor_apply_external_change( &mut self, host: HostId, path: &Path, observed: Observed, window: &mut Window, cx: &mut Context, ) { 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(); } 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, id: BufferId, window: &mut Window, cx: &mut Context, ) { 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(); f.reload_seq = f.reload_seq.wrapping_add(1); let seq = f.reload_seq; HostOps::run_in( host, window, cx, move |h| { let bytes = h.read_file(&target, MAX_FILE_BYTES)?; 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((decoded, mtime)) }, 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 (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.conflict = None; f.saved = Fingerprint::of_str(&decoded.text); f.format = decoded.format; f.saved_format = decoded.format; let input = f.input.clone(); 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(TabCode::all_files) .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); self.editor.nav.forget_buffer(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; }; // The left group can be empty while the right is not: it held only // files that are not recorded (untitled, remote). if state.files.is_empty() && state.split.as_ref().is_none_or(|s| s.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()); // Both groups' files in one trip, the left group's first. let files: Vec = state .files .iter() .chain(state.split.iter().flat_map(|s| s.files.iter())) .cloned() .collect(); let requested = 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 mut opened: Vec<(PathBuf, BufferId)> = Vec::new(); for (path, l) in requested.into_iter().zip(loaded) { if let Some(l) = l { let id = app.editor_install(host.clone(), l, tab_id, false, window, cx); opened.push((path, id)); } } let group = |paths: &[PathBuf], active: usize| -> (Vec, usize) { let front = paths.get(active); let mut ids: Vec = Vec::new(); let mut at = 0; for path in paths { let Some(&(_, id)) = opened.iter().find(|(p, _)| p == path) else { continue; }; if ids.contains(&id) { continue; } if Some(path) == front { at = ids.len(); } ids.push(id); } (ids, at) }; let (left, left_active) = group(&state.files, state.active); let right = state .split .as_ref() .map(|s| (group(&s.files, s.active), s.focused)); 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; }; let restored: Vec = opened.iter().map(|(_, id)| *id).collect(); // Merged into what the tab has now, not written over it: a // file opened, or a split made, while these were loading stays. let untouched = code.restore_groups( &restored, (left, left_active), right.map(|((files, active), focused)| (files, active, focused)), ); let visible = state.visible && !code.files.is_empty(); code.visible |= visible; // Only a restore nobody has touched takes the keyboard; one the // reader has moved on from leaves it where they put it. if untouched && tab_ix == app.active && 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; } // Only files on the window's own machine: an SFTP buffer's host // is a connection that will not exist next launch. let group = |ids: &[BufferId], front: usize| -> (Vec, usize) { let mut files = Vec::new(); let mut active = 0; for (pos, id) in ids.iter().enumerate() { let Some(f) = self.buffer(*id) else { continue }; if f.untitled.is_some() || f.host.id() != spawn_host { continue; } if pos == front { active = files.len(); } files.push(f.path.clone()); } (files, active) }; let sides = code.sides(); let (files, active) = group(&sides[0].files, sides[0].active); let split = sides.get(1).and_then(|right| { let (files, active) = group(&right.files, right.active); (!files.is_empty()).then_some(editor_session::SplitEditor { files, active, focused: right.focused, }) }); let state = TabEditor { files, active, visible: code.visible, split, }; 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(TabCode::all_files) else { return; }; for id in ids { if self.buffer_tabs(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); self.editor.nav.forget_tab(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 { pub(crate) fn render_code_overlay( &mut self, chrome: DocumentChrome, window: &mut Window, cx: &mut Context, ) -> Option { if !self.code_panel_visible() { return None; } self.editor_split_follow_focus(window, cx); self.editor_gutter_sync(cx); self.editor_nav_tick(cx); let breadcrumbs = self.render_editor_breadcrumbs(window, cx); let body = self.render_editor_body( self.tab_code().and_then(TabCode::active_id), self.tab_code().map_or(0, TabCode::focused_slot), cx, ); let conflict_banner = self .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() .then(|| self.render_editor_header(chrome, window, cx)); let editor_col = v_flex() .flex_1() .min_w_0() .h_full() .children(header) .when_some(conflict_banner, |this, b| this.child(b)) .children(bar) .child(self.render_editor_groups(breadcrumbs, body, window, cx)) .children(self.render_editor_problems(window, cx)); // The panel's own paint is the same either way; only the box is not. // Filling the workspace means stopping the window's translucency and // repainting the theme image the root's copy now sits under; docking // means sitting in the same plane as the right panel, which the column // wrapper has already painted. let shell = v_flex().id("code-panel"); let shell = match chrome { DocumentChrome::Fill => shell .absolute() .inset_0() .occlude() // Opaque on purpose: this overlay covers the whole workspace // (everything but the detail panel) and an open file must never // let the window translucency / backdrop material show through // it. The preset's gradient fill is preserved, just with // alpha 1 — the same paint the settings overlay uses. The // theme background image is repainted on top of it, since the // root's copy now sits below this fill. .bg(crate::ui::theme::overlay_background(cx)) .children(crate::ui::app::overlay_surface_layers(cx)), DocumentChrome::Dock | DocumentChrome::DockHoisted => shell.size_full().min_w_0(), }; let shell = self.editor_nav_actions(shell, cx); let shell = self.editor_split_command_sync(shell, cx); Some( shell .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, window, cx| { if ev.keystroke.key != "escape" { return; } if this.editor_gutter_close_peek(cx) { cx.stop_propagation(); 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)) .into_any_element(), ) } /// One group's text: the file `id`, rendered or as source, or the empty /// panel. `slot` tells the two groups' elements apart. pub(crate) fn render_editor_body( &self, id: Option, slot: usize, cx: &mut Context, ) -> AnyElement { match id.and_then(|id| self.buffer(id)) { None => self.render_editor_empty(cx).into_any_element(), Some(f) if f.preview => { let markdown = f.input.read(cx).text().to_string(); let scroll = f.preview_scroll.clone(); // The bar's wrapper takes its height from `flex_1`, so it needs // a column with a definite height to grow inside — hand it one // rather than dropping it straight into the overlay, or the // pane sizes to its content and there is nothing left to // scroll. v_flex() .size_full() .child(crate::ui::scrollbar::with_vertical_scrollbar( ("editor-md-preview-scrollbar", slot), div() .id(("editor-md-preview", slot)) .size_full() .overflow_y_scroll() .track_scroll(&scroll) .px_4() .py_3() .child( gpui_component::text::TextView::markdown( ("editor-md-preview-body", slot), markdown, ) .style(crate::ui::theme::markdown_style(cx)), ), &scroll, )) .into_any_element() } Some(f) => { let input = f.input.clone(); let id = f.id(); let app = cx.entity().downgrade(); div() .id(("editor-body", slot)) .size_full() .child( Input::new(&input) .appearance(false) .font_family(cx.theme().mono_font_family.clone()) .text_size(cx.theme().mono_font_size) .size_full(), ) .children(self.render_editor_gutter_peek(id, cx)) .context_menu(move |menu, _window, cx| { Self::editor_body_menu(menu, &app, id, cx) }) .into_any_element() } } } /// The editor header alone, for the strip above a docked column. pub(crate) fn render_editor_header_only( &self, chrome: DocumentChrome, window: &mut Window, cx: &mut Context, ) -> gpui::AnyElement { self.render_editor_header(chrome, window, cx) .into_any_element() } fn render_editor_header( &self, chrome: DocumentChrome, window: &mut Window, cx: &mut Context, ) -> impl IntoElement + use<> { // `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. let lead = if self.left_panel_open(cx) || chrome.is_dock() { crate::ui::app::CONTENT_INSET } else { crate::ui::app::TITLE_BAR_LEAD }; let row = h_flex().id("editor-header"); let row = if chrome.header_is_title_strip() { crate::ui::app::title_bar_drag(row, "editor-header", window, cx) } else { row }; let menu_app = cx.entity().downgrade(); // 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(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().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); })), ), ) // 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( Button::new("editor-panel-close").icon(Icon::new(IconName::Close)), tile, glyph, false, cx, ) .rounded(px(crate::ui::tab_strip::RAIL_TILE_RADIUS)) .tooltip(t(L10nKey::EditorBackToTerminal)) .on_click(cx.listener(|this, _, window, cx| { this.toggle_code_panel(window, cx); })), ), ) .context_menu(move |menu, _window, cx| { Tty7App::document_header_menu(menu, &menu_app, cx) }) } /// 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); }), ) .context_menu({ let app = cx.entity().downgrade(); move |menu, _window, cx| Self::editor_tab_menu(menu, &app, pos, 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), BarKind::Rename { .. } => t(L10nKey::LspRenameSymbol), }; 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 { // No path here: the breadcrumbs over the text show where the file is. let muted = cx.theme().muted_foreground; let active = self.active_buffer(); let cursor: Option = active.map(|f| { let input = f.input.read(cx); let pos = input.cursor_position(); let ln_col = t_fmt( L10nKey::EditorLnCol, &[ ("line", &(pos.line + 1).to_string()), ("column", &(pos.character + 1).to_string()), ], ); // Multi-cursor: the position is the primary caret's, the count // says there are more. match input.selection_count() { 1 => ln_col.into(), n => { let n = n.to_string(); format!( "{ln_col} {}", t_fmt(L10nKey::EditorSelections, &[("n", &n)]) ) .into() } } }); let wrap: Option = active.map(|f| f.wrap); 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. h_flex() .flex_none() .w_full() .h(px(26.)) .items_center() .gap_3() .px(px(crate::ui::app::CONTENT_INSET)) .border_t(crate::ui::theme::hairline(window)) .border_color(cx.theme().sidebar_border) .text_size(gpui::rems(crate::ui::right_panel::META)) .text_color(muted) .child(div().flex_1()) // The counts open the Problems list. .children(self.render_lsp_status(cx).map(|status| { div() .id("status-problems") .flex_none() .cursor_pointer() .on_click(cx.listener(|this, _, _, cx| this.toggle_editor_problems(cx))) .child(status) })) .when(is_markdown, |this| { this.child( Button::new("status-md-preview") .label(if preview { t(L10nKey::EditorEdit) } else { t(L10nKey::EditorPreview) }) .custom(crate::ui::tab_strip::chrome_tile_variant(cx)) .xsmall() .on_click(cx.listener(|this, _, _w, cx| this.toggle_document_preview(cx))), ) }) .when_some(wrap, |this, wrap| { this.child( Button::new("status-wrap") .label(if wrap { t(L10nKey::EditorWrapOn) } else { t(L10nKey::EditorWrapOff) }) .custom(crate::ui::tab_strip::chrome_tile_variant(cx)) .xsmall() .on_click( cx.listener(|this, _, window, cx| { this.toggle_document_wrap(window, cx) }), ), ) }) .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| { this.child( div() .font_features(crate::ui::theme::tabular_figures()) .child(t), ) }) } fn render_editor_empty(&self, cx: &Context) -> gpui::Div { v_flex() .size_full() .items_center() .justify_center() .gap_2() .child( Icon::new(IconName::File) .large() .text_color(cx.theme().muted_foreground), ) .child( div() .text_sm() .text_color(cx.theme().muted_foreground) .child(crate::ui::i18n::t( crate::ui::i18n::L10nKey::OpenFileFromTree, )), ) } fn render_editor_conflict_banner( &self, id: BufferId, conflict: DiskConflict, cx: &mut Context, ) -> AnyElement { use crate::ui::dialog::{self, Tone}; let theme = cx.theme(); let rungs = cx.global::().window; // The floating notices' grammar, laid flat: a neutral strip over a // hairline, and the state carried by one amber dot. A strip tinted // amber end to end, with a white outlined button from the component // library beside a bare-text one, was three visual languages in one // 32px row. // // The filled answer is always the safe one: Keep mine / Save hold on // to the unsaved edits, Reload / Close throw them away, so those are // offered, not pressed on the reader. let row = h_flex() .flex_none() .w_full() .items_center() .gap(px(8.)) .pl(px(dialog::INSET)) .pr(px(6.)) .h(px(dialog::FOOTER_H)) .border_b_1() .border_color(theme.border) .text_size(rems(crate::ui::right_panel::TAB_TEXT)) .child( div() .flex_none() .size(px(6.)) .rounded_full() .bg(theme.warning), ); let message = |key| div().flex_1().min_w_0().truncate().child(t(key)); match conflict { DiskConflict::Changed(observed) => row .child(message(L10nKey::FileChangedOnDisk)) .child(dialog::button( "editor-conflict-reload", t(L10nKey::Reload), Tone::Secondary, true, rungs, cx, cx.listener(move |this, _, window, cx| { this.editor_reload_from_disk(id, window, cx); }), )) .child(dialog::button( "editor-conflict-keep", t(L10nKey::KeepMine), Tone::Primary, true, rungs, cx, 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(message(L10nKey::EditorFileDeletedOnDisk)) .child(dialog::button( "editor-deleted-close", t(L10nKey::Close), Tone::Secondary, true, rungs, cx, cx.listener(move |this, _, _w, cx| { this.editor_drop_buffer(id, cx); }), )) .child(dialog::button( "editor-deleted-save", t(L10nKey::Save), Tone::Primary, true, rungs, cx, cx.listener(move |this, _, window, cx| { this.editor_save_file(id, false, true, window, cx); }), )) .into_any_element(), } } } #[cfg(test)] mod tests { use super::*; /// A temp dir as the editor will see paths under it: through the local /// host's `canonicalize`, the call `load_file` makes. That resolves /// macOS's `/var` → `/private/var` and Windows' 8.3 short names /// (`RUNNER~1`), and never answers in Windows' `\\?\` form. pub(crate) fn test_real_dir(dir: &Path) -> PathBuf { tty7_core::host::local::LocalHost::new() .canonicalize(dir) .unwrap() } /// Handing a file the editor cannot read to the desktop is how a click /// opens a PNG. It must not be how a click runs a build's output. #[cfg(unix)] #[test] fn a_file_the_desktop_would_run_is_not_handed_to_it() { use std::os::unix::fs::PermissionsExt; let dir = std::env::temp_dir().join(format!("tty7-program-{}", std::process::id())); std::fs::create_dir_all(&dir).expect("create dir"); let image = dir.join("shot.png"); let program = dir.join("built"); std::fs::write(&image, b"\x89PNG\0\0").expect("write image"); std::fs::write(&program, b"\x7fELF\0\0").expect("write program"); std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)) .expect("mark executable"); assert!(!is_program(&image), "a picture is only ever shown"); assert!(is_program(&program), "a binary would be launched"); assert!( !is_program(&dir), "a directory is not a program, whatever its mode says" ); let _ = std::fs::remove_dir_all(&dir); } #[test] fn language_map_covers_common_extensions() { for (path, lang) in [ ("a/b/main.rs", "rust"), ("x.tsx", "tsx"), ("x.jsx", "javascript"), ("x.yml", "yaml"), ("Makefile", "make"), ("CMakeLists.txt", "cmake"), (".zshrc", "bash"), ("notes.md", "markdown"), ("query.SQL", "sql"), ("unknown.xyz", "text"), ("no_ext", "text"), ] { assert_eq!(language_for_path(Path::new(path)), lang, "path {path}"); } } fn mt(secs: i64, nanos: u32) -> Option { Some(MTime { secs, nanos }) } #[test] fn external_changes_are_told_apart_from_our_own_saves() { let ours = mt(100, 0); let seen = |m| Observed::Present(m); assert_eq!( classify_external_change(false, false, ours, seen(ours)), ExternalChange::Ignore ); assert_eq!( classify_external_change(false, false, ours, seen(mt(101, 0))), ExternalChange::Reload ); assert_eq!( classify_external_change(false, true, ours, seen(mt(101, 0))), ExternalChange::Conflict ); assert_eq!( classify_external_change(false, false, mt(100, 0), seen(mt(100, 1))), ExternalChange::Reload ); assert_eq!( classify_external_change(true, false, ours, seen(mt(101, 0))), ExternalChange::Ignore ); assert_eq!( classify_external_change(false, false, None, seen(None)), ExternalChange::Reload ); } #[test] fn a_file_that_vanished_is_reported_deleted_whether_or_not_it_was_edited() { assert_eq!( classify_external_change(false, false, mt(1, 0), Observed::Missing), ExternalChange::Deleted ); assert_eq!( 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!( 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!( 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 an_agent_is_told_the_lines_a_selection_covers() { let text = gpui_component::input::Rope::from("one\ntwo\nthree\n"); assert_eq!(selected_lines(&text, 0..0), None); assert_eq!(selected_lines(&text, 1..2), Some((1, 1))); assert_eq!(selected_lines(&text, 2..9), Some((1, 3))); // Whole lines picked by dragging to the start of the next one. assert_eq!(selected_lines(&text, 4..14), Some((2, 3))); assert_eq!(selected_lines(&text, 0..4), Some((1, 1))); } #[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" ); } }