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.
This commit is contained in:
l0ng-ai
2026-09-28 17:12:45 +08:00
parent e06ba47017
commit 3f4388d4dc
11 changed files with 1128 additions and 2 deletions
+7
View File
@@ -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,
+4
View File
@@ -128,6 +128,10 @@ actions!(
EditorSaveAs,
EditorGoToLine,
EditorNewFile,
EditorNextChange,
EditorPrevChange,
EditorRevertChange,
ToggleEditorGitGutter,
OpenSshProfiles,
RestartSshSession,
SendTab,
+6 -2
View File
@@ -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::<crate::terminal::git_status::GitStatusCache>()?
.repo_root_for(host, path.parent()?)?;
.try_global::<crate::terminal::git_status::GitStatusCache>()
.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()))
}
+20
View File
@@ -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)),
)
+19
View File
@@ -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() {
File diff suppressed because it is too large Load Diff
+9
View File
@@ -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…",
+9
View File
@@ -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 => "名前を付けて保存…",
+7
View File
@@ -936,6 +936,13 @@ l10n_keys! {
EditorGoToLine,
EditorGoToLineAction,
EditorCopyRelativePath,
EditorGitNextChange,
EditorGitPrevChange,
EditorGitRevertChange,
EditorGitToggleGutter,
EditorGitPeekRevert,
EditorGitPeekSummary,
EditorGitPeekAddedOnly,
EditorGoToLinePlaceholder,
EditorSaveAs,
EditorSaveAsAction,
+7
View File
@@ -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 => "另存为…",
+26
View File
@@ -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<KeyBinding> {
"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),