diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index a2e5444b..726f001a 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -263,6 +263,12 @@ pub struct Config { /// [`Self::editor_soft_wrap`]. Files that are not Markdown ignore it. #[serde(default)] pub editor_markdown_preview: bool, + /// Whether the code editor marks lines that differ from the file's + /// staged (index) version in its gutter. On by default; flipped by the + /// `ToggleEditorGitGutter` command and remembered like + /// [`Self::editor_soft_wrap`]. + #[serde(default = "default_true")] + pub editor_git_gutter: bool, /// Whether the sidebar files tabs nobody pinned into groups of its own — /// by repository, and by host for an SSH pane. Off, those tabs sit in one /// flat list below the pinned groups, which are the user's and show @@ -759,6 +765,7 @@ impl Default for Config { scm_changes_tree: false, editor_soft_wrap: false, editor_markdown_preview: false, + editor_git_gutter: true, sidebar_auto_grouping: true, notify_on_command_finish: NotifyMode::Unfocused, check_for_updates: true, diff --git a/src/core/actions.rs b/src/core/actions.rs index 1fb5be95..c545803b 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -128,6 +128,10 @@ actions!( EditorSaveAs, EditorGoToLine, EditorNewFile, + EditorNextChange, + EditorPrevChange, + EditorRevertChange, + ToggleEditorGitGutter, OpenSshProfiles, RestartSshSession, SendTab, diff --git a/src/terminal/git_data.rs b/src/terminal/git_data.rs index b1461a58..07085e64 100644 --- a/src/terminal/git_data.rs +++ b/src/terminal/git_data.rs @@ -890,8 +890,12 @@ impl Tty7App { // it against a local repository that merely shares the path. let (host, path) = self.editor_active_location()?; let root = cx - .try_global::()? - .repo_root_for(host, path.parent()?)?; + .try_global::() + .and_then(|cache| cache.repo_root_for(host, path.parent()?)) + // The gutter's own base read finds the repository of a file no + // terminal has been in, and its markers need this watch to + // notice a stage or a checkout. + .or_else(|| self.editor_gutter_root())?; Some((host, root.to_path_buf())) } diff --git a/src/ui/app.rs b/src/ui/app.rs index 49db39e6..90c9c040 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -9362,6 +9362,26 @@ impl Render for Tty7App { .on_action(cx.listener(|this, _: &EditorNewFile, window, cx| { this.editor_new_file(window, cx) })) + // The gutter's change markers. Each one lets the chord through + // when the editor is not focused or has nothing to do. + .on_action(cx.listener(|this, _: &EditorNextChange, window, cx| { + if !this.editor_gutter_step(true, window, cx) { + cx.propagate(); + } + })) + .on_action(cx.listener(|this, _: &EditorPrevChange, window, cx| { + if !this.editor_gutter_step(false, window, cx) { + cx.propagate(); + } + })) + .on_action(cx.listener(|this, _: &EditorRevertChange, window, cx| { + if !this.editor_gutter_revert_at_cursor(window, cx) { + cx.propagate(); + } + })) + .on_action(cx.listener(|this, _: &ToggleEditorGitGutter, _, cx| { + this.toggle_editor_git_gutter(cx) + })) .on_action( cx.listener(|this, _: &Quit, window, cx| this.quit_stop_sessions(window, cx)), ) diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs index b03f169e..44194be2 100644 --- a/src/ui/code_editor.rs +++ b/src/ui/code_editor.rs @@ -22,6 +22,8 @@ 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; + const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024; const RELOAD_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(200); @@ -117,6 +119,8 @@ pub(crate) struct OpenFile { /// 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, } @@ -1247,6 +1251,7 @@ impl Tty7App { .default_value(text) }); let id = input.entity_id(); + let git_gutter = gutter::BufferGutter::attach(&input, id, cx); let sub = cx.subscribe_in( &input, window, @@ -1277,6 +1282,7 @@ impl Tty7App { preview, wrap, preview_scroll: gpui::ScrollHandle::new(), + gutter: git_gutter, _sub: sub, _observe: observe, }); @@ -1297,6 +1303,7 @@ impl Tty7App { if let Some(f) = self.buffer_mut(id) { f.dirty = dirty; } + self.editor_gutter_note_edit(id, cx); cx.notify(); } @@ -1587,6 +1594,7 @@ impl Tty7App { f.saved_format = format; f.conflict = None; app.editor_note_edit(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. @@ -2104,6 +2112,11 @@ impl Tty7App { .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), ); this.editor_file_menu_items(menu, id, app, cx) } @@ -2823,6 +2836,7 @@ impl Tty7App { if !self.code_panel_visible() { return None; } + self.editor_gutter_sync(cx); let body = match self.active_buffer() { None => self.render_editor_empty(cx).into_any_element(), Some(f) if f.preview => { @@ -2869,6 +2883,7 @@ impl Tty7App { .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) }) @@ -2921,6 +2936,10 @@ impl Tty7App { 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() { diff --git a/src/ui/code_editor/gutter.rs b/src/ui/code_editor/gutter.rs new file mode 100644 index 00000000..38adcae8 --- /dev/null +++ b/src/ui/code_editor/gutter.rs @@ -0,0 +1,1014 @@ +//! Git change markers in the code editor's gutter. +//! +//! Each buffer is diffed against its file's **index** version (`git show +//! :path`) — what VS Code's quick diff compares with, and what the source +//! control panel lists as "Changes": staging a hunk makes its marker go away. +//! Untracked files, files outside a repository and hosts without git (SFTP) +//! get no markers at all, again as VS Code does. +//! +//! The base is fetched through the buffer's own host, so a file on a remote +//! workspace is compared with *that* machine's repository. It is re-read when +//! the repository's SCM epoch moves (a `.git` watch event, a stage, a commit, +//! a checkout — see `ScmData::bump`), when the file is saved, and when the +//! buffer's path changes. The diff itself is recomputed off the UI thread a +//! moment after each edit; in between, the editor shifts the markers along +//! with the text (see `gpui_component::input::GutterMarker`). +use std::ops::Range; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use gpui::prelude::*; +use gpui::{ + AnyElement, Context, EntityInputHandler as _, Pixels, Point, Task, Window, anchored, deferred, + div, px, +}; +use gpui_component::button::{Button, ButtonVariants as _}; +use gpui_component::input::{GutterMarker, GutterMarkerClick, InputState, Position, RopeExt as _}; +use gpui_component::{ActiveTheme as _, IconName, Sizable as _, h_flex, v_flex}; + +use super::{BufferId, TabCode}; +use crate::ui::app::Tty7App; +use crate::ui::editor_text::TextFormat; +use crate::ui::host_ops::HostOps; +use crate::ui::i18n::{L10nKey, t, t_fmt}; + +/// How long typing has to pause before the buffer is diffed again. +const DIFF_DEBOUNCE: Duration = Duration::from_millis(150); + +/// Past this many differing lines (after trimming what the two sides share at +/// either end) the exact diff is not worth its cost: the region between the +/// first and last difference is marked as one modified block instead. +const MAX_EDIT_DISTANCE: usize = 1000; + +/// One contiguous difference: base lines `old` became buffer lines `new`. +/// Lines are counted the way the editor counts them — a text ending in `\n` +/// has an empty last line, and line `i` of the diff is line `i` on screen. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Hunk { + pub(crate) old: Range, + pub(crate) new: Range, +} + +impl Hunk { + fn marker(&self) -> GutterMarker { + if self.old.is_empty() { + GutterMarker::added(self.new.clone()) + } else if self.new.is_empty() { + GutterMarker::deleted(self.new.start) + } else { + GutterMarker::modified(self.new.clone()) + } + } +} + +/// The file's lines, each with its terminator. `split_inclusive` gives no +/// entry for the empty line after a final `\n`, which is exactly right: that +/// line exists on both sides or on neither, and its presence shows up as the +/// previous line gaining or losing its `\n`. +fn lines(text: &str) -> Vec<&str> { + text.split_inclusive('\n').collect() +} + +/// A line as compared: `\r\n` and `\n` are the same line. Save writes one +/// line ending for the whole file, so a pasted `\r\n` is not a change. +fn line_key(line: &str) -> &str { + match line.strip_suffix("\r\n") { + Some(body) => &line[..body.len()], + None => line.strip_suffix('\n').unwrap_or(line), + } +} + +/// Whether the line had a terminator — kept apart from [`line_key`] so a +/// last line that lost its `\n` still counts as changed. +fn line_id(line: &str) -> (&str, bool) { + (line_key(line), line.ends_with('\n')) +} + +/// The differences between `base` and `current`, in order. +pub(crate) fn diff_hunks(base: &str, current: &str) -> Vec { + let a = lines(base); + let b = lines(current); + // Intern the lines so the diff compares integers. + fn intern<'a>(ids: &mut std::collections::HashMap<(&'a str, bool), u32>, l: &'a str) -> u32 { + let next = ids.len() as u32; + *ids.entry(line_id(l)).or_insert(next) + } + let mut ids = std::collections::HashMap::new(); + let a: Vec = a.iter().map(|l| intern(&mut ids, l)).collect(); + let b: Vec = b.iter().map(|l| intern(&mut ids, l)).collect(); + + let prefix = a.iter().zip(&b).take_while(|(x, y)| x == y).count(); + let suffix = a[prefix..] + .iter() + .rev() + .zip(b[prefix..].iter().rev()) + .take_while(|(x, y)| x == y) + .count(); + let a_mid = &a[prefix..a.len() - suffix]; + let b_mid = &b[prefix..b.len() - suffix]; + if a_mid.is_empty() && b_mid.is_empty() { + return Vec::new(); + } + let matches = myers_matches(a_mid, b_mid, MAX_EDIT_DISTANCE).unwrap_or_default(); + + let mut hunks = Vec::new(); + let (mut ai, mut bi) = (0, 0); + for (x, y) in matches.into_iter().chain([(a_mid.len(), b_mid.len())]) { + if x > ai || y > bi { + hunks.push(Hunk { + old: prefix + ai..prefix + x, + new: prefix + bi..prefix + y, + }); + } + ai = x + 1; + bi = y + 1; + } + hunks +} + +/// The pairs `(i, j)` with `a[i] == b[j]` on a shortest edit script, in +/// order — Myers' O(ND) algorithm. `None` when the edit distance exceeds +/// `max_d`. +fn myers_matches(a: &[u32], b: &[u32], max_d: usize) -> Option> { + let n = a.len() as isize; + let m = b.len() as isize; + let max = ((n + m) as usize).min(max_d) as isize; + let offset = max + 1; + let mut v = vec![0isize; 2 * offset as usize + 1]; + // `trace[d]` is `v` as it stood before round `d`, only the part round `d` + // reads: diagonals `-d - 1 ..= d + 1`. + let mut trace: Vec> = Vec::new(); + let mut found = None; + 'outer: for d in 0..=max { + let lo = (offset - d - 1).max(0) as usize; + let hi = (offset + d + 1) as usize; + trace.push(v[lo..=hi].to_vec()); + let mut k = -d; + while k <= d { + let ix = (offset + k) as usize; + let mut x = if k == -d || (k != d && v[ix - 1] < v[ix + 1]) { + v[ix + 1] + } else { + v[ix - 1] + 1 + }; + let mut y = x - k; + while x < n && y < m && a[x as usize] == b[y as usize] { + x += 1; + y += 1; + } + v[ix] = x; + if x >= n && y >= m { + found = Some(d); + break 'outer; + } + k += 2; + } + } + let d_final = found?; + + let mut matches = Vec::new(); + let (mut x, mut y) = (n, m); + for d in (0..=d_final).rev() { + // The snapshot starts at diagonal `-d - 1`. + let snap = &trace[d as usize]; + let at = |k: isize| snap[(k + d + 1) as usize]; + let k = x - y; + let prev_k = if k == -d || (k != d && at(k - 1) < at(k + 1)) { + k + 1 + } else { + k - 1 + }; + let prev_x = at(prev_k); + let prev_y = prev_x - prev_k; + // The snake: equal lines back to where this round's one edit landed. + while x > prev_x && y > prev_y { + x -= 1; + y -= 1; + matches.push((x as usize, y as usize)); + } + x = prev_x; + y = prev_y; + } + matches.reverse(); + Some(matches) +} + +/// The edit that puts `hunk`'s base lines back: a byte range of `current` and +/// the text to put there. +pub(crate) fn revert_edit(current: &str, base: &str, hunk: &Hunk) -> (Range, String) { + let offset = |text: &str, line: usize| -> usize { + text.split_inclusive('\n') + .take(line) + .map(str::len) + .sum::() + }; + let start = offset(current, hunk.new.start); + let end = start + + current + .split_inclusive('\n') + .skip(hunk.new.start) + .take(hunk.new.len()) + .map(str::len) + .sum::(); + let replacement: String = base + .split_inclusive('\n') + .skip(hunk.old.start) + .take(hunk.old.len()) + .collect(); + (start..end, replacement) +} + +/// The hunk the cursor on `line` is in. A deletion belongs to the lines on +/// both sides of the boundary it sits on. +pub(crate) fn hunk_at_line(hunks: &[Hunk], line: usize) -> Option { + hunks + .iter() + .position(|h| h.new.contains(&line)) + .or_else(|| { + hunks + .iter() + .position(|h| h.new.is_empty() && (h.new.start == line || h.new.start == line + 1)) + }) +} + +/// The hunk to jump to from the cursor on `line`, wrapping around the file. +pub(crate) fn step_hunk(hunks: &[Hunk], line: usize, forward: bool) -> Option { + if hunks.is_empty() { + return None; + } + if forward { + hunks.iter().position(|h| h.new.start > line).or(Some(0)) + } else { + // "Previous" from inside a hunk is the one before it, not its start. + let here = hunk_at_line(hunks, line).filter(|&i| hunks[i].new.start <= line); + let before = match here { + Some(i) => i.checked_sub(1), + None => hunks.iter().rposition(|h| h.new.start < line), + }; + before.or(Some(hunks.len() - 1)) + } +} + +/// The base as the editor would show it: decoded the way the buffer was, and +/// with `\r\n` turned into `\n` like every buffer. +/// +/// The encoding is the buffer's rather than a fresh guess: a short index +/// version could sniff differently from the file it is an older copy of. +pub(crate) fn decode_base(bytes: &[u8], format: &TextFormat) -> String { + let encoding = format.encoding; + let text = if encoding == encoding_rs::UTF_8 { + let body = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes); + String::from_utf8_lossy(body).into_owned() + } else { + let body = if encoding == encoding_rs::UTF_16LE { + bytes.strip_prefix(b"\xFF\xFE").unwrap_or(bytes) + } else if encoding == encoding_rs::UTF_16BE { + bytes.strip_prefix(b"\xFE\xFF").unwrap_or(bytes) + } else { + bytes + }; + encoding.decode_without_bom_handling(body).0.into_owned() + }; + if text.contains("\r\n") { + text.replace("\r\n", "\n") + } else { + text + } +} + +/// What the host thread brings back for a buffer's base. +struct Fetched { + root: Option, + bytes: Option>, +} + +fn fetch_base(h: &dyn tty7_core::host::Host, path: &Path) -> Fetched { + let none = Fetched { + root: None, + bytes: None, + }; + let (Some(dir), Some(name)) = (path.parent(), path.file_name().and_then(|n| n.to_str())) else { + return none; + }; + let Some(root) = h.repo_root(dir).ok().flatten() else { + return none; + }; + let root = tty7_core::core::path_spelling::spelling_on_buf(h.id(), root); + // `:./name` is the index entry of `name` relative to the cwd. It fails for + // an untracked file and for one mid-merge (no stage 0), and both mean "no + // markers". + let spec = format!(":./{name}"); + let bytes = h + .git(dir, &["show", &spec]) + .ok() + .filter(|out| out.success()) + .map(|out| out.stdout); + Fetched { + root: Some(root), + bytes, + } +} + +/// The inline peek a click on a marker opens. +struct Peek { + hunk: Hunk, + /// The base lines the hunk replaced. + original: String, + position: Point, +} + +/// Which read of the base a buffer holds, or wants. +#[derive(Clone, Debug, PartialEq, Eq)] +struct BaseKey { + path: PathBuf, + epoch: u64, +} + +/// One buffer's change markers. +#[derive(Default)] +pub(crate) struct BufferGutter { + /// The index version, decoded. `None` for no markers. + base: Option>, + /// The repository the file is in, once a fetch has found out. + root: Option, + /// What `base` was read for; a different wanted key means read again. + fetched: Option, + fetching: bool, + hunks: Arc>, + /// Bumped on every edit; `diffed` is the value `hunks` were computed at. + edits: u64, + diffed: u64, + diff_task: Option>, + peek: Option, +} + +impl BufferGutter { + /// Hooks a new buffer's input up to the gutter: clicking a marker opens + /// the peek. + pub(crate) fn attach( + input: &gpui::Entity, + id: BufferId, + cx: &mut Context, + ) -> Self { + let app = cx.entity().downgrade(); + input.update(cx, |state, _| { + state.on_gutter_marker_click(move |click, _window, cx| { + let _ = app.update(cx, |app, cx| app.editor_gutter_open_peek(id, click, cx)); + }); + }); + Self::default() + } + + /// The repository the buffer's file is in, if a base read found one. + pub(crate) fn root(&self) -> Option<&Path> { + self.root.as_deref() + } + + #[cfg(test)] + pub(crate) fn set_base_for_test(&mut self, base: &str) { + self.base = Some(base.into()); + self.edits += 1; + } +} + +impl Tty7App { + /// The repository of the file in front, as its gutter's base read found + /// it — for the SCM watch, which otherwise only knows the repositories + /// terminals have been in. + pub(crate) fn editor_gutter_root(&self) -> Option<&Path> { + self.active_buffer()?.gutter.root() + } +} + +fn enabled(cx: &gpui::App) -> bool { + cx.global::().editor_git_gutter +} + +fn scm_epoch(cx: &gpui::App, host: crate::ui::host_ops::HostId, root: &Path) -> u64 { + cx.try_global::() + .map(|data| data.epoch(host, root)) + .unwrap_or(0) +} + +impl Tty7App { + /// Once a frame while the editor is on screen: make sure the file in + /// front has a base that is current for its repository. + pub(crate) fn editor_gutter_sync(&mut self, cx: &mut Context) { + if !enabled(cx) { + return; + } + let Some(id) = self.tab_code().and_then(TabCode::active_id) else { + return; + }; + let Some(f) = self.buffer(id) else { + return; + }; + if f.untitled.is_some() || f.gutter.fetching { + return; + } + let host = f.host.clone(); + let wanted = BaseKey { + path: f.path.clone(), + epoch: f + .gutter + .root + .as_deref() + .map_or(0, |root| scm_epoch(cx, host.id(), root)), + }; + if f.gutter.fetched.as_ref() == Some(&wanted) { + return; + } + let format = f.format; + if let Some(f) = self.buffer_mut(id) { + f.gutter.fetching = true; + } + let path = wanted.path.clone(); + HostOps::run( + host, + cx, + move |h| fetch_base(h, &path), + move |app, fetched: Fetched, cx| { + let Some(f) = app.buffer_mut(id) else { + return; + }; + f.gutter.fetching = false; + f.gutter.fetched = Some(wanted); + f.gutter.root = fetched.root; + let base: Option> = fetched + .bytes + .map(|bytes| decode_base(&bytes, &format).into()); + if f.gutter.base != base { + f.gutter.base = base; + // Not an edit, but the hunks are just as stale. + f.gutter.edits += 1; + app.editor_gutter_schedule(id, Duration::ZERO, cx); + } + cx.notify(); + }, + ); + } + + /// Read the base again on the next frame — after a save, or when the + /// setting comes back on. + pub(crate) fn editor_gutter_refetch(&mut self, id: BufferId) { + if let Some(f) = self.buffer_mut(id) { + f.gutter.fetched = None; + } + } + + /// The buffer's text changed. + pub(crate) fn editor_gutter_note_edit(&mut self, id: BufferId, cx: &mut Context) { + let Some(f) = self.buffer_mut(id) else { + return; + }; + f.gutter.edits += 1; + f.gutter.peek = None; + if f.gutter.base.is_some() { + self.editor_gutter_schedule(id, DIFF_DEBOUNCE, cx); + } + } + + /// Diff the buffer against its base after `delay`, off the UI thread. + fn editor_gutter_schedule(&mut self, id: BufferId, delay: Duration, cx: &mut Context) { + let Some(f) = self.buffer_mut(id) else { + return; + }; + let task = cx.spawn(async move |this, cx| { + if !delay.is_zero() { + cx.background_executor().timer(delay).await; + } + let Ok(Some((base, text, at))) = this.update(cx, |app, cx| { + let f = app.buffer(id)?; + let base = f.gutter.base.clone()?; + Some((base, f.input.read(cx).text().clone(), f.gutter.edits)) + }) else { + return; + }; + let hunks = cx + .background_executor() + .spawn(async move { diff_hunks(&base, &text.to_string()) }) + .await; + let _ = this.update(cx, |app, cx| app.editor_gutter_install(id, at, hunks, cx)); + }); + f.gutter.diff_task = Some(task); + } + + fn editor_gutter_install( + &mut self, + id: BufferId, + at: u64, + hunks: Vec, + cx: &mut Context, + ) { + let on = enabled(cx); + let Some(f) = self.buffer_mut(id) else { + return; + }; + // An edit landed while this was computing; its own diff is on the way. + if f.gutter.edits != at { + return; + } + f.gutter.diffed = at; + f.gutter.hunks = Arc::new(hunks); + let markers = if on { + f.gutter.hunks.iter().map(Hunk::marker).collect() + } else { + Vec::new() + }; + f.input + .clone() + .update(cx, |state, cx| state.set_gutter_markers(markers, cx)); + } + + /// The hunks for the text as it is right now, diffing on the spot if an + /// edit is still waiting out its debounce. + fn editor_gutter_hunks_now(&mut self, id: BufferId, cx: &mut Context) -> Arc> { + let Some(f) = self.buffer(id) else { + return Arc::default(); + }; + let Some(base) = f.gutter.base.clone().filter(|_| enabled(cx)) else { + return Arc::default(); + }; + if f.gutter.diffed != f.gutter.edits { + let at = f.gutter.edits; + let text = f.input.read(cx).text().to_string(); + let hunks = diff_hunks(&base, &text); + self.editor_gutter_install(id, at, hunks, cx); + } + self.buffer(id) + .map(|f| f.gutter.hunks.clone()) + .unwrap_or_default() + } + + fn editor_gutter_active(&self, window: &Window, cx: &Context) -> Option { + if !self.editor_panel_has_focus(window, cx) { + return None; + } + self.tab_code().and_then(TabCode::active_id) + } + + /// Next / previous change: put the cursor on the first line of the next + /// hunk, wrapping around the file. `false` when there is nowhere to go, so + /// the caller can let the key through. + pub(crate) fn editor_gutter_step( + &mut self, + forward: bool, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(id) = self.editor_gutter_active(window, cx) else { + return false; + }; + let hunks = self.editor_gutter_hunks_now(id, cx); + let Some(f) = self.buffer(id) else { + return false; + }; + let input = f.input.clone(); + let line = input.read(cx).cursor_position().line as usize; + let Some(ix) = step_hunk(&hunks, line, forward) else { + return false; + }; + let last = input.read(cx).text().lines_len().saturating_sub(1); + let target = hunks[ix].new.start.min(last); + input.update(cx, |state, cx| { + state.set_cursor_position(Position::new(target as u32, 0), window, cx); + }); + true + } + + /// Whether the cursor sits in a change that could be reverted — for the + /// right-click menu, which reads the hunks as they were last drawn. + pub(crate) fn editor_gutter_can_revert(&self, id: BufferId, cx: &gpui::App) -> bool { + let Some(f) = self.buffer(id) else { + return false; + }; + if f.gutter.base.is_none() || !enabled(cx) { + return false; + } + let line = f.input.read(cx).cursor_position().line as usize; + hunk_at_line(&f.gutter.hunks, line).is_some() + } + + /// Put back the base lines of the change under the cursor, as one edit. + pub(crate) fn editor_gutter_revert_at_cursor( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(id) = self.editor_gutter_active(window, cx) else { + return false; + }; + let hunks = self.editor_gutter_hunks_now(id, cx); + let Some(f) = self.buffer(id) else { + return false; + }; + let line = f.input.read(cx).cursor_position().line as usize; + let Some(ix) = hunk_at_line(&hunks, line) else { + return false; + }; + self.editor_gutter_revert(id, &hunks[ix], window, cx) + } + + fn editor_gutter_revert( + &mut self, + id: BufferId, + hunk: &Hunk, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(f) = self.buffer(id) else { + return false; + }; + let Some(base) = f.gutter.base.clone() else { + return false; + }; + let input = f.input.clone(); + let text = input.read(cx).text().to_string(); + let (range, replacement) = revert_edit(&text, &base, hunk); + let start16 = text[..range.start].encode_utf16().count(); + let end16 = start16 + text[range.clone()].encode_utf16().count(); + let line = hunk.new.start; + input.update(cx, |state, cx| { + state.replace_text_in_range(Some(start16..end16), &replacement, window, cx); + let last = state.text().lines_len().saturating_sub(1); + state.set_cursor_position(Position::new(line.min(last) as u32, 0), window, cx); + }); + if let Some(f) = self.buffer_mut(id) { + f.gutter.peek = None; + } + cx.notify(); + true + } + + /// The setting was flipped: drop or bring back every buffer's markers. + pub(crate) fn toggle_editor_git_gutter(&mut self, cx: &mut Context) { + let on = !enabled(cx); + self.update_config(cx, |cfg| cfg.editor_git_gutter = on); + let ids: Vec = self.editor.buffers.iter().map(|f| f.id()).collect(); + for id in ids { + let Some(f) = self.buffer_mut(id) else { + continue; + }; + f.gutter.peek = None; + if on { + f.gutter.fetched = None; + } else { + f.gutter = BufferGutter::default(); + f.input + .clone() + .update(cx, |state, cx| state.set_gutter_markers(Vec::new(), cx)); + } + } + cx.notify(); + } + + fn editor_gutter_open_peek( + &mut self, + id: BufferId, + click: &GutterMarkerClick, + cx: &mut Context, + ) { + let Some(f) = self.buffer(id) else { + return; + }; + let Some(base) = f.gutter.base.clone() else { + return; + }; + let start = click.marker.lines.start; + let Some(hunk) = f + .gutter + .hunks + .iter() + .find(|h| h.new.start == start) + .cloned() + else { + return; + }; + let original = base + .split_inclusive('\n') + .skip(hunk.old.start) + .take(hunk.old.len()) + .collect(); + if let Some(f) = self.buffer_mut(id) { + f.gutter.peek = Some(Peek { + hunk, + original, + position: click.position, + }); + } + cx.notify(); + } + + /// Escape closes the peek before it closes anything else. + pub(crate) fn editor_gutter_close_peek(&mut self, cx: &mut Context) -> bool { + let Some(id) = self.tab_code().and_then(TabCode::active_id) else { + return false; + }; + let Some(f) = self.buffer_mut(id) else { + return false; + }; + if f.gutter.peek.take().is_none() { + return false; + } + cx.notify(); + true + } + + /// The peek for buffer `id`, when one is open: the lines the change + /// replaced and a Revert button. + pub(crate) fn render_editor_gutter_peek( + &self, + id: BufferId, + cx: &mut Context, + ) -> Option { + let peek = self.buffer(id)?.gutter.peek.as_ref()?; + let theme = cx.theme(); + let removed = peek.hunk.old.len(); + let added = peek.hunk.new.len(); + let summary = t_fmt( + L10nKey::EditorGitPeekSummary, + &[ + ("removed", &removed.to_string()), + ("added", &added.to_string()), + ], + ); + let body: AnyElement = if peek.original.is_empty() { + div() + .px_3() + .py_2() + .text_sm() + .text_color(theme.muted_foreground) + .child(t(L10nKey::EditorGitPeekAddedOnly)) + .into_any_element() + } else { + let text = peek.original.strip_suffix('\n').unwrap_or(&peek.original); + div() + .id("editor-gutter-peek-body") + .max_h(px(240.)) + .overflow_y_scroll() + .bg(theme.danger.opacity(0.08)) + .px_3() + .py_1() + .font_family(theme.mono_font_family.clone()) + .text_size(theme.mono_font_size) + .children( + text.split('\n') + .map(|l| div().whitespace_nowrap().child(l.to_string())), + ) + .into_any_element() + }; + let app = cx.entity().downgrade(); + let hunk = peek.hunk.clone(); + let header = h_flex() + .gap_2() + .px_3() + .py_1p5() + .border_b_1() + .border_color(theme.border) + .child( + div() + .flex_1() + .text_sm() + .text_color(theme.muted_foreground) + .child(summary), + ) + .child( + Button::new("editor-gutter-peek-revert") + .small() + .label(t(L10nKey::EditorGitPeekRevert)) + .on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = app.update(cx, |app, cx| { + app.editor_gutter_revert(id, &hunk, window, cx); + }); + } + }), + ) + .child( + Button::new("editor-gutter-peek-close") + .ghost() + .xsmall() + .icon(IconName::Close) + .on_click({ + let app = app.clone(); + move |_, _, cx| { + let _ = app.update(cx, |app, cx| app.editor_gutter_close_peek(cx)); + } + }), + ); + let card = v_flex() + .id("editor-gutter-peek") + .occlude() + .min_w(px(280.)) + .max_w(px(640.)) + .bg(theme.popover) + .text_color(theme.popover_foreground) + .border_1() + .border_color(theme.border) + .rounded(theme.radius) + .shadow_lg() + .overflow_hidden() + .child(header) + .child(body) + .on_mouse_down_out(move |_, _, cx| { + let _ = app.update(cx, |app, cx| app.editor_gutter_close_peek(cx)); + }); + Some( + deferred( + anchored() + .position(peek.position + gpui::point(px(8.), px(4.))) + .snap_to_window_with_margin(px(8.)) + .child(card), + ) + .with_priority(1) + .into_any_element(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui_component::input::GutterMarkerKind; + + fn h(old: Range, new: Range) -> Hunk { + Hunk { old, new } + } + + #[test] + fn identical_texts_have_no_hunks() { + assert!(diff_hunks("", "").is_empty()); + assert!(diff_hunks("a\nb\n", "a\nb\n").is_empty()); + } + + #[test] + fn added_lines_at_the_start_middle_and_end() { + assert_eq!(diff_hunks("b\nc\n", "a\nb\nc\n"), vec![h(0..0, 0..1)]); + assert_eq!(diff_hunks("a\nc\n", "a\nb\nb2\nc\n"), vec![h(1..1, 1..3)]); + assert_eq!(diff_hunks("a\n", "a\nb\n"), vec![h(1..1, 1..2)]); + // Typing on the empty last line of a file that ends in a newline. + assert_eq!(diff_hunks("a\n", "a\nb"), vec![h(1..1, 1..2)]); + assert_eq!(diff_hunks("", "a\n"), vec![h(0..0, 0..1)]); + } + + #[test] + fn deleted_lines_at_the_start_middle_and_end() { + assert_eq!(diff_hunks("a\nb\nc\n", "b\nc\n"), vec![h(0..1, 0..0)]); + assert_eq!(diff_hunks("a\nb\nc\n", "a\nc\n"), vec![h(1..2, 1..1)]); + assert_eq!(diff_hunks("a\nb\nc\n", "a\nb\n"), vec![h(2..3, 2..2)]); + assert_eq!(diff_hunks("a\n", ""), vec![h(0..1, 0..0)]); + } + + #[test] + fn modified_lines_and_separate_hunks() { + assert_eq!(diff_hunks("a\nb\nc\n", "a\nB\nc\n"), vec![h(1..2, 1..2)]); + assert_eq!( + diff_hunks("1\n2\n3\n4\n5\n6\n", "1\nX\n3\n4\n6\n7\n"), + vec![h(1..2, 1..2), h(4..5, 4..4), h(6..6, 5..6)] + ); + // One line replaced by two is one hunk, not a delete and an add. + assert_eq!(diff_hunks("a\nb\nc\n", "a\nx\ny\nc\n"), vec![h(1..2, 1..3)]); + } + + #[test] + fn a_lost_final_newline_is_a_change_to_the_last_line() { + assert_eq!(diff_hunks("a\nb\n", "a\nb"), vec![h(1..2, 1..2)]); + } + + #[test] + fn a_pasted_crlf_is_not_a_change() { + assert!(diff_hunks("a\nb\n", "a\r\nb\n").is_empty()); + } + + #[test] + fn diffs_are_minimal_on_interleaved_edits() { + let base: String = (0..200).map(|i| format!("line {i}\n")).collect(); + let current: String = (0..200) + .map(|i| match i % 7 { + 0 => format!("changed {i}\n"), + 3 => String::new(), + _ => format!("line {i}\n"), + }) + .collect(); + let hunks = diff_hunks(&base, ¤t); + let changed = (0..200).filter(|i| i % 7 == 0 || i % 7 == 3).count(); + assert_eq!(hunks.len(), changed); + // Applying every revert, last first, gives the base back. + let mut text = current.clone(); + for hunk in hunks.iter().rev() { + let (range, replacement) = revert_edit(&text, &base, hunk); + text.replace_range(range, &replacement); + } + assert_eq!(text, base); + } + + #[test] + fn a_huge_rewrite_falls_back_to_one_block() { + let base: String = (0..3000).map(|i| format!("a{i}\n")).collect(); + let current: String = (0..3000).map(|i| format!("b{i}\n")).collect(); + let hunks = diff_hunks(&base, &format!("keep\n{current}")); + assert_eq!(hunks, vec![h(0..3000, 0..3001)]); + } + + #[test] + fn markers_follow_the_hunk_kinds() { + let kinds: Vec<_> = diff_hunks("a\nb\nc\nd\ne\n", "a\nB\nc\ne\nf\n") + .iter() + .map(|h| { + let m = h.marker(); + (m.lines, m.kind) + }) + .collect(); + assert_eq!( + kinds, + vec![ + (1..2, GutterMarkerKind::Modified), + (3..3, GutterMarkerKind::Deleted), + (4..5, GutterMarkerKind::Added), + ] + ); + } + + #[test] + fn reverting_puts_the_base_lines_back() { + let base = "a\nb\nc\n"; + for current in [ + "a\nB\nc\n", + "a\nc\n", + "a\nb\nx\nc\n", + "x\na\nb\nc\n", + "a\nb\nc\nz", + "a\nb\nc", + ] { + let hunks = diff_hunks(base, current); + assert_eq!(hunks.len(), 1, "{current:?}"); + let (range, replacement) = revert_edit(current, base, &hunks[0]); + let mut text = current.to_string(); + text.replace_range(range, &replacement); + assert_eq!(text, base, "reverting {current:?}"); + } + } + + #[test] + fn the_cursor_finds_its_hunk_and_steps_between_them() { + let hunks = vec![h(1..2, 1..2), h(4..5, 4..4), h(6..6, 5..7)]; + assert_eq!(hunk_at_line(&hunks, 1), Some(0)); + assert_eq!(hunk_at_line(&hunks, 2), None); + // A deletion belongs to the lines on either side of it. + assert_eq!(hunk_at_line(&hunks, 3), Some(1)); + assert_eq!(hunk_at_line(&hunks, 4), Some(1)); + assert_eq!(hunk_at_line(&hunks, 6), Some(2)); + + assert_eq!(step_hunk(&hunks, 0, true), Some(0)); + assert_eq!(step_hunk(&hunks, 1, true), Some(1)); + assert_eq!(step_hunk(&hunks, 5, true), Some(0), "wraps"); + assert_eq!(step_hunk(&hunks, 6, false), Some(1)); + assert_eq!(step_hunk(&hunks, 3, false), Some(0)); + assert_eq!(step_hunk(&hunks, 0, false), Some(2), "wraps"); + assert_eq!(step_hunk(&[], 0, true), None); + } + + #[test] + fn the_base_is_decoded_the_way_the_buffer_was() { + use crate::ui::editor_text::{LineEnding, TextFormat}; + + let crlf = TextFormat { + line_ending: LineEnding::CrLf, + ..TextFormat::default() + }; + assert_eq!(decode_base(b"a\r\nb\r\n", &crlf), "a\nb\n"); + // An index that holds LF for a CRLF checkout (autocrlf) compares the same. + assert_eq!(decode_base(b"a\nb\n", &crlf), "a\nb\n"); + assert_eq!( + decode_base(b"\xEF\xBB\xBFhi\n", &TextFormat::default()), + "hi\n" + ); + + let gb = TextFormat { + encoding: encoding_rs::GB18030, + ..TextFormat::default() + }; + let (bytes, _, _) = encoding_rs::GB18030.encode("中文\n"); + assert_eq!(decode_base(&bytes, &gb), "中文\n"); + + let utf16 = TextFormat { + encoding: encoding_rs::UTF_16LE, + bom: true, + ..TextFormat::default() + }; + let mut bytes = vec![0xFF, 0xFE]; + for unit in "é\r\n".encode_utf16() { + bytes.extend(unit.to_le_bytes()); + } + assert_eq!(decode_base(&bytes, &utf16), "é\n"); + } + + #[test] + fn step_wraps_forward_past_the_last_hunk() { + let hunks = vec![h(1..2, 1..2)]; + assert_eq!(step_hunk(&hunks, 1, true), Some(0)); + assert_eq!(step_hunk(&hunks, 9, true), Some(0)); + } +} diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 22f913e7..2243d05d 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1202,6 +1202,15 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::EditorGoToLine => "Go to line", L10nKey::EditorGoToLineAction => "Go to Line…", L10nKey::EditorCopyRelativePath => "Copy Relative Path", + L10nKey::EditorGitNextChange => "Go to Next Change", + L10nKey::EditorGitPrevChange => "Go to Previous Change", + L10nKey::EditorGitRevertChange => "Revert Change", + L10nKey::EditorGitToggleGutter => "Toggle Git Change Markers", + L10nKey::EditorGitPeekRevert => "Revert", + L10nKey::EditorGitPeekSummary => "−{removed} +{added} lines against the staged version", + L10nKey::EditorGitPeekAddedOnly => { + "These lines are new: the staged version has nothing here." + } L10nKey::EditorGoToLinePlaceholder => "Line, or line:column (1–{total})", L10nKey::EditorSaveAs => "Save as", L10nKey::EditorSaveAsAction => "Save As…", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index dfebc354..736c60ef 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1250,6 +1250,15 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::EditorGoToLine => "行へ移動", L10nKey::EditorGoToLineAction => "行へ移動…", L10nKey::EditorCopyRelativePath => "相対パスをコピー", + L10nKey::EditorGitNextChange => "次の変更へ移動", + L10nKey::EditorGitPrevChange => "前の変更へ移動", + L10nKey::EditorGitRevertChange => "変更を元に戻す", + L10nKey::EditorGitToggleGutter => "Git 変更マーカーの切り替え", + L10nKey::EditorGitPeekRevert => "元に戻す", + L10nKey::EditorGitPeekSummary => "ステージ済みの版と比べて −{removed} +{added} 行", + L10nKey::EditorGitPeekAddedOnly => { + "新しく追加された行です。ステージ済みの版にはここに何もありません。" + } L10nKey::EditorGoToLinePlaceholder => "行番号、または 行:列(1–{total})", L10nKey::EditorSaveAs => "名前を付けて保存", L10nKey::EditorSaveAsAction => "名前を付けて保存…", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 746dbdb1..9cc8f25e 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -936,6 +936,13 @@ l10n_keys! { EditorGoToLine, EditorGoToLineAction, EditorCopyRelativePath, + EditorGitNextChange, + EditorGitPrevChange, + EditorGitRevertChange, + EditorGitToggleGutter, + EditorGitPeekRevert, + EditorGitPeekSummary, + EditorGitPeekAddedOnly, EditorGoToLinePlaceholder, EditorSaveAs, EditorSaveAsAction, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 7c1fc618..cb75d1b0 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1122,6 +1122,13 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::EditorGoToLine => "跳转到行", L10nKey::EditorGoToLineAction => "跳转到行…", L10nKey::EditorCopyRelativePath => "复制相对路径", + L10nKey::EditorGitNextChange => "转到下一处更改", + L10nKey::EditorGitPrevChange => "转到上一处更改", + L10nKey::EditorGitRevertChange => "还原此处更改", + L10nKey::EditorGitToggleGutter => "切换 Git 更改标记", + L10nKey::EditorGitPeekRevert => "还原", + L10nKey::EditorGitPeekSummary => "与暂存版本相比 −{removed} +{added} 行", + L10nKey::EditorGitPeekAddedOnly => "这些是新增的行:暂存版本中此处没有内容。", L10nKey::EditorGoToLinePlaceholder => "行号,或 行:列(1–{total})", L10nKey::EditorSaveAs => "另存为", L10nKey::EditorSaveAsAction => "另存为…", diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index e28d7b91..095824da 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -592,6 +592,12 @@ fn shipped_bindings() -> Vec<(&'static str, &'static str)> { ("EditorGoToLine", "ctrl-g"), // Unbound: ⌘N is New Window, and the editor's header has a + for it. ("EditorNewFile", ""), + // Alt+F5 / Shift+Alt+F5 (VS Code's) are fixed `Input`-context + // bindings: a default on a function key would hide it from the shell. + ("EditorNextChange", ""), + ("EditorPrevChange", ""), + ("EditorRevertChange", ""), + ("ToggleEditorGitGutter", ""), ("OpenSshProfiles", ""), ("RestartSshSession", "secondary-shift-r"), ("Quit", per_platform("secondary-q", "secondary-shift-q")), @@ -882,6 +888,22 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> { CommandGroup::Terminal, t(L10nKey::EditorNewFile).to_string(), ), + "EditorNextChange" => ( + CommandGroup::Terminal, + t(L10nKey::EditorGitNextChange).to_string(), + ), + "EditorPrevChange" => ( + CommandGroup::Terminal, + t(L10nKey::EditorGitPrevChange).to_string(), + ), + "EditorRevertChange" => ( + CommandGroup::Terminal, + t(L10nKey::EditorGitRevertChange).to_string(), + ), + "ToggleEditorGitGutter" => ( + CommandGroup::Terminal, + t(L10nKey::EditorGitToggleGutter).to_string(), + ), "OpenSshProfiles" => ( CommandGroup::Ssh, t(L10nKey::CmdSshManageProfiles).to_string(), @@ -1573,6 +1595,10 @@ fn make_binding(action: &str, keystroke: &str) -> Option { "EditorSaveAs" => KeyBinding::new(keystroke, EditorSaveAs, None), "EditorGoToLine" => KeyBinding::new(keystroke, EditorGoToLine, None), "EditorNewFile" => KeyBinding::new(keystroke, EditorNewFile, None), + "EditorNextChange" => KeyBinding::new(keystroke, EditorNextChange, None), + "EditorPrevChange" => KeyBinding::new(keystroke, EditorPrevChange, None), + "EditorRevertChange" => KeyBinding::new(keystroke, EditorRevertChange, None), + "ToggleEditorGitGutter" => KeyBinding::new(keystroke, ToggleEditorGitGutter, None), "OpenSshProfiles" => KeyBinding::new(keystroke, OpenSshProfiles, None), "RestartSshSession" => KeyBinding::new(keystroke, RestartSshSession, None), "Quit" => KeyBinding::new(keystroke, Quit, None),