From 3f4388d4dc8ca588f464e7275135d0a45e6dfda5 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:12:45 +0800 Subject: [PATCH] feat(editor): git change markers in the gutter Diff each buffer against its file's index version (what VS Code's quick diff and the SCM panel's Changes compare with) and hand the hunks to the editor as gutter markers: added, modified, and a wedge where lines were deleted. The base is read with `git show :./name` through the buffer's own host, so remote workspaces work too; SFTP buffers and untracked files get no markers. It is re-read when the repository's SCM epoch moves, on save and when the path changes; the diff itself is a line-level Myers diff run off the UI thread shortly after each edit. Next/previous change and Revert Change (one undo step, also in the right-click menu) are commands; clicking a marker opens a peek of the staged lines with a Revert button. The editor watch now also follows the repository the gutter found, so a stage or checkout refreshes the markers. The `editor_git_gutter` config (default on) is flipped by the ToggleEditorGitGutter command. --- crates/tty7-core/src/core/config.rs | 7 + src/core/actions.rs | 4 + src/terminal/git_data.rs | 8 +- src/ui/app.rs | 20 + src/ui/code_editor.rs | 19 + src/ui/code_editor/gutter.rs | 1014 +++++++++++++++++++++++++++ src/ui/i18n/en.rs | 9 + src/ui/i18n/ja.rs | 9 + src/ui/i18n/mod.rs | 7 + src/ui/i18n/zh.rs | 7 + src/ui/keymap.rs | 26 + 11 files changed, 1128 insertions(+), 2 deletions(-) create mode 100644 src/ui/code_editor/gutter.rs 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),