use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::Arc; use gpui::prelude::*; use gpui::{ AnyElement, Context, Entity, Focusable as _, PromptLevel, SharedString, Subscription, Window, div, px, }; use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::input::{Input, InputEvent, InputState, Position, TabSize}; use gpui_component::menu::ContextMenuExt as _; use gpui_component::{ ActiveTheme as _, Icon, IconName, Sizable as _, WindowExt as _, h_flex, v_flex, }; use crate::ui::app::Tty7App; use crate::ui::document_column::DocumentChrome; use crate::ui::host_ops::{HostId, HostOps, MTime, SharedHost, WatchSub}; use crate::ui::i18n::{L10nKey, t, t_fmt}; const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024; const RELOAD_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(200); pub(crate) struct OpenFile { pub(crate) path: PathBuf, /// 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, disk_mtime: Option, edit_seq: u64, saving: Option, save_pending: bool, save_then_close: bool, reload_seq: u64, pub(crate) conflict: bool, 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, _sub: Subscription, _observe: Subscription, } impl OpenFile { fn label(&self) -> SharedString { self.path .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| self.path.display().to_string()) .into() } } 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, } 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, } } pub(crate) fn active_file(&self) -> Option<&OpenFile> { self.files.get(self.active) } } pub(crate) struct EditorPanelState { /// 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)>, 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>, } 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 { pending_cursor: None, 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, } } } 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" => "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(); } fn looks_binary(bytes: &[u8]) -> bool { bytes.iter().take(8192).any(|b| *b == 0) } #[derive(Debug, PartialEq, Eq)] enum ExternalChange { Ignore, Conflict, Reload, } fn classify_external_change( saving: bool, dirty: bool, disk_mtime: Option, observed: Option, ) -> ExternalChange { if saving { return ExternalChange::Ignore; } if observed.is_some() && observed == disk_mtime { return ExternalChange::Ignore; } if dirty { ExternalChange::Conflict } else { ExternalChange::Reload } } #[derive(Debug, PartialEq, Eq)] struct SaveLanding { clean: bool, requeue: bool, } fn settle_save(ok: bool, wrote_seq: u64, current_seq: u64, pending: bool) -> SaveLanding { SaveLanding { clean: ok && wrote_seq == current_seq, requeue: ok && pending, } } 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 editor_rebuild_watcher(&mut self, cx: &mut Context) { // Only files on the host the watch itself runs on. A path from // another machine — an SFTP file, say — does not exist under that // watcher's feet, and would either miss or, worse, match a local file // that happens to share its name. // // A host that cannot watch therefore gets no external-change // detection at all: an SFTP buffer will not notice the file changing // underneath it, and saving overwrites whatever is there. Catching // that at save time needs a "keep mine" that survives to the next // save, which the conflict banner does not have yet. let watch_host = self.spawn_host(cx); let files: HashSet = self .tabs .iter() .filter_map(|t| t.code.as_deref()) .flat_map(|c| c.files.iter()) .filter(|f| 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; 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, host: HostId, requested: &Path, opened: &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.tab_code().and_then(|c| { c.files .iter() .find(|f| f.host.id() == host && f.path == *opened) }) else { return; }; 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, ) { if self.tabs.get(self.active).is_none() { return; } self.raise_code_overlay(); if self.editor_activate_open(host.id(), path, window, cx) { return; } let host_id = host.id(); let p = path.to_path_buf(); let requested = p.clone(); HostOps::run_in( host.clone(), window, cx, move |h| -> Result<(PathBuf, String, Option), String> { let path = h.canonicalize(&p).unwrap_or(p); let meta = match h.stat(&path) { Ok(m) => m, Err(e) => { return Err(t_fmt( L10nKey::EditorCantOpen, &[ ("path", &path.display().to_string()), ("e", &crate::ui::host_ops::explain_io(&e)), ], )); } }; if meta.len > MAX_FILE_BYTES { return Err(t_fmt( L10nKey::EditorFileTooLarge, &[ ("path", &path.display().to_string()), ("size", &(meta.len / (1024 * 1024)).to_string()), ], )); } let bytes = match h.read_file(&path, MAX_FILE_BYTES) { Ok(b) => b, Err(e) => { return Err(t_fmt( L10nKey::EditorCantRead, &[ ("path", &path.display().to_string()), ("e", &crate::ui::host_ops::explain_io(&e)), ], )); } }; if looks_binary(&bytes) { return Err(t_fmt( L10nKey::EditorBinaryFile, &[("path", &path.display().to_string())], )); } let text = String::from_utf8(bytes).map_err(|_| { t_fmt( L10nKey::EditorNotUtf8, &[("path", &path.display().to_string())], ) })?; Ok((path, text, meta.mtime)) }, move |app, opened, window, cx| match opened { Ok((path, text, mtime)) => { app.editor_install_file(host, path.clone(), text, mtime, window, cx); // Against `requested`, not `path`: the host canonicalised // it on the way through, and a link that named a symlink // would otherwise lose the line it asked for. app.apply_pending_cursor(host_id, &requested, &path, window, cx); } Err(message) => window.push_notification(message, cx), }, ); } fn editor_activate_open( &mut self, host: HostId, path: &Path, window: &mut Window, cx: &mut Context, ) -> bool { let Some(code) = self.tab_code_mut() else { return false; }; let Some(ix) = code .files .iter() .position(|f| f.host.id() == host && f.path == *path) else { return false; }; code.visible = true; let f = code.files.remove(ix); code.files.insert(0, f); code.active = 0; self.focus_editor(window, cx); self.apply_pending_cursor(host, path, path, window, cx); cx.notify(); true } fn editor_install_file( &mut self, host: SharedHost, path: PathBuf, text: String, mtime: Option, window: &mut Window, cx: &mut Context, ) { let host_id = host.id(); if self.editor_activate_open(host_id, &path, window, cx) { return; } if self.tabs.get(self.active).is_none() { return; } let language = language_for_path(&path); let input = cx.new(|cx| { InputState::new(window, cx) .code_editor(language) .multi_line(true) .tab_size(TabSize { tab_size: 4, hard_tabs: false, }) .line_number(true) .searchable(true) .replaceable(true) .folding(true) .soft_wrap(false) .default_value(text) }); let sub = cx.subscribe_in(&input, window, { let path = path.clone(); move |this: &mut Tty7App, _input, ev, _window, cx| { if matches!(ev, InputEvent::Change) { let Some(f) = this .tabs .iter_mut() .filter_map(|t| t.code.as_deref_mut()) .flat_map(|c| c.files.iter_mut()) .find(|f| f.host.id() == host_id && f.path == path) else { return; }; f.dirty = true; f.edit_seq = f.edit_seq.wrapping_add(1); cx.notify(); } } }); let tab = self .tabs .get_mut(self.active) .expect("checked at function entry"); let code = tab.code.get_or_insert_with(|| Box::new(TabCode::new())); let observe = cx.observe(&input, |_, _, cx| cx.notify()); code.files.insert( 0, OpenFile { path, host, input, dirty: false, disk_mtime: mtime, edit_seq: 0, saving: None, save_pending: false, save_then_close: false, reload_seq: 0, conflict: false, preview: false, wrap: false, preview_scroll: gpui::ScrollHandle::new(), _sub: sub, _observe: observe, }, ); code.active = 0; code.visible = true; self.editor_rebuild_watcher(cx); self.focus_editor(window, cx); cx.notify(); } 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.file_tree.editing = None; self.focus_active(window, cx); cx.notify(); return; } code.visible = true; self.file_tree_refresh_roots(window, cx); if self.tab_code().is_some_and(|c| c.active_file().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.tab_code().and_then(|c| c.active_file()) { f.input.update(cx, |input, cx| input.focus(window, cx)); } } pub(crate) fn editor_has_focus(&self, window: &Window, cx: &Context) -> bool { self.code_panel_visible() && self .tab_code() .and_then(|c| c.active_file()) .is_some_and(|f| { f.input .read(cx) .focus_handle(cx) .contains_focused(window, cx) }) } pub(crate) fn editor_save_active(&mut self, window: &mut Window, cx: &mut Context) { let Some(id) = self .tab_code() .and_then(|c| c.active_file()) .map(|f| f.input.entity_id()) else { return; }; self.editor_save_file(id, false, window, cx); } fn editor_save_file( &mut self, id: gpui::EntityId, then_close: bool, window: &mut Window, cx: &mut Context, ) { // The file's own host, not the active one: the buffer keeps pointing // at the machine it was read from, however the focus has moved since. let Some(host) = self.editor_file_mut(id).map(|f| f.host.clone()) else { return; }; let Some(f) = self.editor_file_mut(id) else { return; }; f.save_then_close |= then_close; if f.saving.is_some() { f.save_pending = true; return; } let seq = f.edit_seq; f.saving = Some(seq); let text = f.input.read(cx).text().to_string(); let target = f.path.clone(); let host_id = host.id(); let saved_in = target.parent().map(std::path::Path::to_path_buf); HostOps::run_in( host, window, cx, move |h| h.write_file(&target, text.as_bytes()).map(|m| m.mtime), move |app, result: std::io::Result>, window, cx| { let Some(f) = app.editor_file_mut(id) else { return; }; f.saving = None; let landing = settle_save( result.is_ok(), seq, f.edit_seq, std::mem::take(&mut f.save_pending), ); let wrote = result.is_ok(); match result { Ok(mtime) => { f.disk_mtime = mtime; } Err(e) => { // "Save failed" did not say which file, and with more // than one editor tab open that is the first thing you // need to know. let name = f .path .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| f.path.display().to_string()); let context = t_fmt(L10nKey::EditorSaveFailed, &[("name", &name)]); HostOps::notify_err(window, cx, &context, &e); } } if landing.clean { f.dirty = false; f.conflict = false; } // A save is a working-tree edit the `.git` watch cannot see, // and the file tree only sees it while it happens to be showing // that directory. if wrote && let Some(dir) = &saved_in { app.scm_invalidate_cwd(host_id, dir, cx); } if landing.requeue { app.editor_save_file(id, false, window, cx); cx.notify(); return; } let close = app .editor_file_mut(id) .is_some_and(|f| std::mem::take(&mut f.save_then_close) && !f.dirty); if close && let Some((tab_ix, ix)) = app.editor_file_position(id) { app.editor_remove_file_in(tab_ix, ix, cx); } cx.notify(); }, ); cx.notify(); } fn editor_file_mut(&mut self, id: gpui::EntityId) -> Option<&mut OpenFile> { self.tabs .iter_mut() .filter_map(|t| t.code.as_deref_mut()) .flat_map(|c| c.files.iter_mut()) .find(|f| f.input.entity_id() == id) } fn editor_file_position(&self, id: gpui::EntityId) -> Option<(usize, usize)> { self.tabs.iter().enumerate().find_map(|(tab_ix, t)| { let code = t.code.as_deref()?; let ix = code.files.iter().position(|f| f.input.entity_id() == id)?; Some((tab_ix, ix)) }) } pub(crate) fn editor_close_file( &mut self, ix: usize, window: &mut Window, cx: &mut Context, ) { let Some(f) = self.tab_code().and_then(|c| c.files.get(ix)) else { return; }; if !f.dirty { self.editor_remove_file(ix, 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, ); let id = f.input.entity_id(); cx.spawn_in(window, async move |app, cx| { let Ok(choice) = answer.await else { return }; let _ = app.update_in(cx, |app, window, cx| match choice { 0 => app.editor_save_file(id, true, window, cx), 2 => { if let Some((tab_ix, ix)) = app.editor_file_position(id) { app.editor_remove_file_in(tab_ix, ix, cx); } } _ => {} }); }) .detach(); } pub(crate) fn editor_close_active_if_focused( &mut self, window: &mut Window, cx: &mut Context, ) -> bool { if !self.editor_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 } fn editor_remove_file(&mut self, ix: usize, cx: &mut Context) { self.editor_remove_file_in(self.active, ix, cx); } fn editor_remove_file_in(&mut self, tab_ix: usize, ix: usize, cx: &mut Context) { let Some(code) = self .tabs .get_mut(tab_ix) .and_then(|t| t.code.as_deref_mut()) else { return; }; if ix >= code.files.len() { return; } code.files.remove(ix); if code.active >= ix && code.active > 0 { code.active -= 1; } self.editor_rebuild_watcher(cx); cx.notify(); } pub(crate) fn editor_handle_external_change( &mut self, path: &Path, window: &mut Window, cx: &mut Context, ) { let Some(host) = self.active_host(cx) else { return; }; let host_id = host.id(); let p = path.to_path_buf(); let landed = p.clone(); HostOps::run_in( host, window, cx, move |h| h.stat(&p).ok().and_then(|m| m.mtime), move |app, mtime, window, cx| { app.editor_apply_external_change(host_id, &landed, mtime, window, cx) }, ); } fn editor_apply_external_change( &mut self, host: HostId, path: &Path, mtime: Option, window: &mut Window, cx: &mut Context, ) { let mut reload: Vec<(usize, usize)> = Vec::new(); let mut changed = false; for (tab_ix, tab) in self.tabs.iter_mut().enumerate() { let Some(code) = tab.code.as_deref_mut() else { continue; }; for (ix, f) in code.files.iter_mut().enumerate() { if f.host.id() != host || f.path != *path { continue; } match classify_external_change(f.saving.is_some(), f.dirty, f.disk_mtime, mtime) { ExternalChange::Ignore => {} ExternalChange::Conflict => { f.conflict = true; changed = true; } ExternalChange::Reload => reload.push((tab_ix, ix)), } } } for (tab_ix, ix) in reload { self.editor_reload_from_disk(tab_ix, ix, window, cx); } if changed { cx.notify(); } } pub(crate) fn editor_reload_from_disk( &mut self, tab_ix: usize, ix: usize, window: &mut Window, cx: &mut Context, ) { let Some(f) = self .tabs .get_mut(tab_ix) .and_then(|t| t.code.as_deref_mut()) .and_then(|c| c.files.get_mut(ix)) else { return; }; let target = f.path.clone(); let host = f.host.clone(); let id = f.input.entity_id(); f.reload_seq = f.reload_seq.wrapping_add(1); let seq = f.reload_seq; HostOps::run_in( host, window, cx, move |h| { let bytes = h.read_file(&target, MAX_FILE_BYTES)?; let text = String::from_utf8(bytes).map_err(|_| { std::io::Error::new(std::io::ErrorKind::InvalidData, "not valid UTF-8") })?; let mtime = h.stat(&target).ok().and_then(|m| m.mtime); Ok((text, mtime)) }, move |app, result: std::io::Result<(String, Option)>, window, cx| { let Some(f) = app.editor_file_mut(id) else { return; }; if f.reload_seq != seq { return; } let Ok((text, mtime)) = result else { f.dirty = true; f.conflict = false; cx.notify(); return; }; f.disk_mtime = mtime; f.dirty = false; f.conflict = false; f.edit_seq = f.edit_seq.wrapping_add(1); let input = f.input.clone(); input.update(cx, |input, cx| input.set_value(text, window, cx)); cx.notify(); }, ); } } 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; } let body = match self.tab_code().and_then(|c| c.active_file()) { 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", div() .id("editor-md-preview") .size_full() .overflow_y_scroll() .track_scroll(&scroll) .px_4() .py_3() .child(gpui_component::text::TextView::markdown( "editor-md-preview-body", markdown, )), &scroll, )) .into_any_element() } Some(f) => { let input = f.input.clone(); Input::new(&input) .appearance(false) .font_family(cx.theme().mono_font_family.clone()) .text_size(cx.theme().mono_font_size) .size_full() .into_any_element() } }; let conflict_banner = self .tab_code() .and_then(|c| c.active_file()) .filter(|f| f.conflict) .map(|_| self.render_editor_conflict_banner(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)) .child(div().flex_1().min_h_0().child(body)); // 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(), }; Some( shell .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, window, cx| { if ev.keystroke.key == "escape" { 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(), ) } /// 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<> { let active = self.tab_code().and_then(|c| c.active_file()); let name = active.map(|f| f.label()); let dirty = active.is_some_and(|f| f.dirty); // `TITLE_BAR_LEAD` is the room macOS's traffic lights need. Only a // header that starts at the left edge of the window has them to clear, // and a docked column never does. 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(); row.flex_none() .h(px(crate::ui::app::TITLE_BAR_HEIGHT)) .items_center() .gap_1p5() .pl(px(lead)) .pr(px(crate::ui::app::tile_trailing_inset())) .border_b_1() .border_color(cx.theme().border) .child( div() .flex_1() .min_w_0() .text_ellipsis() .text_sm() .when(name.is_none(), |d| { d.text_color(cx.theme().muted_foreground) }) .child( name.unwrap_or_else(|| SharedString::from(t(L10nKey::EditorNoFileOpen))), ), ) .when(dirty, |d| { d.child( div() .flex_none() .size(px(6.)) .rounded_full() .bg(cx.theme().warning), ) }) .child( div().occlude().flex_shrink_0().child( crate::ui::tab_strip::chrome_tile_sized( Button::new("editor-panel-close").icon(Icon::new(IconName::Close)), crate::ui::app::TILE_SIZE, crate::ui::app::TILE_GLYPH_LINE, false, cx, ) .rounded_lg() .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) }) } fn render_code_status_bar(&self, _window: &Window, cx: &mut Context) -> gpui::Div { // The roots below belong to this window's own machine. A file read // over SFTP is on another one, where they mean nothing, so it shows // its own full path rather than borrowing the local repo's name. let tree_host = self.spawn_host(cx); let code = self.tab_code(); let muted = cx.theme().muted_foreground; let path_text: Option = code.map(|c| { let repo = c .roots .first() .and_then(|r| r.file_name()) .map(|n| n.to_string_lossy().to_string()) .unwrap_or_default(); match c.active_file() { Some(f) if f.host.id() != tree_host => f.path.display().to_string().into(), Some(f) => { let rel = c .roots .iter() .find_map(|r| f.path.strip_prefix(r).ok()) .map(|p| p.display().to_string()) .unwrap_or_else(|| f.label().to_string()); format!("{repo} › {rel}").into() } None => repo.into(), } }); let active = code.and_then(|c| c.active_file()); let cursor: Option = active.map(|f| { let pos = f.input.read(cx).cursor_position(); t_fmt( L10nKey::EditorLnCol, &[ ("line", &(pos.line + 1).to_string()), ("column", &(pos.character + 1).to_string()), ], ) .into() }); let wrap: Option = active.map(|f| f.wrap); let is_markdown = active.is_some_and(|f| language_for_path(&f.path) == "markdown"); let preview = active.is_some_and(|f| f.preview); h_flex() .flex_none() .w_full() .h(px(26.)) .items_center() .gap_3() .px_3() .border_t_1() .border_color(cx.theme().border) .text_xs() .text_color(muted) .when_some(path_text, |this, t| { this.child(div().min_w_0().text_ellipsis().child(t)) }) .child(div().flex_1()) .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| { if let Some(code) = this.tab_code_mut() { let ix = code.active; if let Some(f) = code.files.get_mut(ix) { f.preview = !f.preview; cx.notify(); } } })), ) }) .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| { let Some(code) = this.tab_code_mut() else { return; }; let ix = code.active; if let Some(f) = code.files.get_mut(ix) { f.wrap = !f.wrap; let wrap = f.wrap; f.input.clone().update(cx, |st, cx| { st.set_soft_wrap(wrap, window, cx); }); } })), ) }) .when_some(cursor, |this, t| this.child(div().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, cx: &mut Context) -> AnyElement { let tab_ix = self.active; let ix = self.tab_code().map(|c| c.active).unwrap_or(0); h_flex() .flex_none() .w_full() .items_center() .gap_2() .px_2() .py_1() .bg(cx.theme().warning.opacity(0.15)) .border_b_1() .border_color(cx.theme().border) .text_sm() .child(div().flex_1().child(crate::ui::i18n::t( crate::ui::i18n::L10nKey::FileChangedOnDisk, ))) .child( Button::new("editor-conflict-reload") .label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::Reload)) .small() .on_click(cx.listener(move |this, _, window, cx| { this.editor_reload_from_disk(tab_ix, ix, window, cx); })), ) .child( Button::new("editor-conflict-keep") .label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::KeepMine)) .ghost() .small() .on_click(cx.listener(move |this, _, _w, cx| { if let Some(f) = this.tab_code_mut().and_then(|c| c.files.get_mut(ix)) { f.conflict = false; cx.notify(); } })), ) .into_any_element() } } #[cfg(test)] mod tests { use super::*; #[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}"); } } #[test] fn binary_sniff_flags_nul_bytes_only() { assert!(looks_binary(b"\x7fELF\x00\x01")); assert!(!looks_binary("plain text\nwith lines".as_bytes())); assert!(!looks_binary("中文 UTF-8 内容".as_bytes())); } fn t(secs: i64, nanos: u32) -> Option { Some(MTime { secs, nanos }) } #[test] fn external_changes_are_told_apart_from_our_own_saves() { let ours = t(100, 0); assert_eq!( classify_external_change(false, false, ours, ours), ExternalChange::Ignore ); assert_eq!( classify_external_change(false, false, ours, t(101, 0)), ExternalChange::Reload ); assert_eq!( classify_external_change(false, true, ours, t(101, 0)), ExternalChange::Conflict ); assert_eq!( classify_external_change(false, false, t(100, 0), t(100, 1)), ExternalChange::Reload ); assert_eq!( classify_external_change(true, false, ours, t(101, 0)), ExternalChange::Ignore ); assert_eq!( classify_external_change(false, false, None, None), ExternalChange::Reload ); } #[test] fn a_landed_save_only_cleans_a_buffer_that_did_not_move() { assert_eq!( settle_save(true, 7, 7, false), SaveLanding { clean: true, requeue: false } ); assert_eq!( settle_save(true, 7, 9, false), SaveLanding { clean: false, requeue: false } ); assert_eq!( settle_save(true, 7, 9, true), SaveLanding { clean: false, requeue: true } ); assert_eq!( settle_save(false, 7, 7, true), SaveLanding { clean: false, requeue: false } ); } }