diff --git a/Cargo.lock b/Cargo.lock index 13140d25f3..5bde0fb31b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2990,6 +2990,13 @@ dependencies = [ "smol", ] +[[package]] +name = "example-tig" +version = "0.7.1" +dependencies = [ + "gpui-kit", +] + [[package]] name = "exr" version = "1.74.2" diff --git a/Cargo.toml b/Cargo.toml index 8ac7747797..d3385b7ac6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ members = [ "examples/hello_world", "examples/input", "examples/editor", + "examples/tig", "examples/brush", "examples/dock", "examples/html", diff --git a/crates/component/src/diff/conflict.rs b/crates/component/src/diff/conflict.rs index def085e7cb..e45d6dc5d3 100644 --- a/crates/component/src/diff/conflict.rs +++ b/crates/component/src/diff/conflict.rs @@ -29,10 +29,18 @@ pub(crate) enum ConflictPart { Incoming, } -/// One conflict region. Ranges index the file's modified-side lines, which -/// exclude the marker lines. +/// A parsed merge conflict in a working file. +/// +/// Source coordinates are one-based, half-open ranges, allowing empty parts. +/// Returned by [`DiffFile::conflicts`] in source order; its slice index is the +/// index accepted by [`super::DiffState::resolve_conflict`]. Fields are private +/// and only the parser constructs these snapshots. #[derive(Clone, Debug)] -pub(crate) struct Conflict { +pub struct DiffConflict { + lines: Range, + current_lines: Range, + base_lines: Option>, + incoming_lines: Range, current: Range, base: Option>, incoming: Range, @@ -41,9 +49,37 @@ pub(crate) struct Conflict { incoming_label: SharedString, } -impl Conflict { +impl DiffConflict { + /// One-based working-file lines, including markers, with an exclusive end. + pub fn lines(&self) -> Range { + self.lines.clone() + } + /// Current-side source lines, with an exclusive end; may be empty. + pub fn current_lines(&self) -> Range { + self.current_lines.clone() + } + /// Base source lines for diff3, with an exclusive end; may be empty. + pub fn base_lines(&self) -> Option> { + self.base_lines.clone() + } + /// Incoming-side source lines, with an exclusive end; may be empty. + pub fn incoming_lines(&self) -> Range { + self.incoming_lines.clone() + } + /// The label from the opening marker. + pub fn current_label(&self) -> &SharedString { + &self.current_label + } + /// The base marker label, or None for a two-way conflict. + pub fn base_label(&self) -> Option<&SharedString> { + self.base.as_ref().map(|_| &self.base_label) + } + /// The label from the closing marker. + pub fn incoming_label(&self) -> &SharedString { + &self.incoming_label + } /// Every line of the region, in file order. - pub(crate) fn lines(&self) -> Range { + pub(crate) fn source_lines(&self) -> Range { self.current.start..self.incoming.end } pub(crate) fn part(&self, part: ConflictPart) -> Option> { @@ -129,7 +165,7 @@ impl DiffFile { let mut pairs = Vec::new(); let mut conflicts = Vec::new(); // The marker-delimited part being read, with the open region's ranges. - let mut open: Option<(ConflictPart, Conflict)> = None; + let mut open: Option<(ConflictPart, DiffConflict)> = None; for (ix, range) in line_ranges(text).into_iter().enumerate() { let line = &text[range.clone()]; let content = line.trim_end_matches(['\n', '\r']); @@ -151,7 +187,11 @@ impl DiffFile { if let Some(label) = opening { open = Some(( ConflictPart::Current, - Conflict { + DiffConflict { + lines: ix + 1..ix + 1, + current_lines: ix + 2..ix + 2, + base_lines: None, + incoming_lines: ix + 2..ix + 2, current: next..next, base: None, incoming: next..next, @@ -176,6 +216,8 @@ impl DiffFile { match (*part, base, separator, closing) { (ConflictPart::Current, Some(label), _, _) => { conflict.current.end = next; + conflict.current_lines.end = ix + 1; + conflict.base_lines = Some(ix + 2..ix + 2); conflict.base = Some(next..next); conflict.base_label = label; *part = ConflictPart::Base; @@ -184,15 +226,20 @@ impl DiffFile { (ConflictPart::Current | ConflictPart::Base, _, Some(_), _) => { if *part == ConflictPart::Current { conflict.current.end = next; + conflict.current_lines.end = ix + 1; } else if let Some(base) = &mut conflict.base { base.end = next; + conflict.base_lines.as_mut().unwrap().end = ix + 1; } conflict.incoming = next..next; + conflict.incoming_lines = ix + 2..ix + 2; *part = ConflictPart::Incoming; continue; } (ConflictPart::Incoming, _, _, Some(label)) => { conflict.incoming.end = next; + conflict.incoming_lines.end = ix + 1; + conflict.lines.end = ix + 2; conflict.incoming_label = label; if let Some((_, conflict)) = open.take() { conflicts.push(conflict); @@ -259,6 +306,13 @@ mod tests { .collect::>(); assert_eq!(numbers, [1, 3, 5, 7]); let conflict = &file.conflicts()[0]; + assert_eq!(conflict.lines(), 2..7); + assert_eq!(conflict.current_lines(), 3..4); + assert_eq!(conflict.incoming_lines(), 5..6); + assert_eq!(conflict.base_lines(), None); + assert_eq!(conflict.base_label(), None); + assert_eq!(conflict.current_label().as_str(), "HEAD"); + assert_eq!(conflict.incoming_label().as_str(), "feature"); assert_eq!(conflict.part(ConflictPart::Current), Some(1..2)); assert_eq!(conflict.part(ConflictPart::Incoming), Some(2..3)); assert_eq!(conflict.label(ConflictPart::Current).as_str(), "HEAD"); @@ -275,6 +329,11 @@ mod tests { ) .unwrap(); let conflict = &file.conflicts()[0]; + assert_eq!(conflict.lines(), 1..8); + assert_eq!(conflict.current_lines(), 2..3); + assert_eq!(conflict.base_lines(), Some(4..5)); + assert_eq!(conflict.incoming_lines(), 6..7); + assert_eq!(conflict.base_label().unwrap().as_str(), "base"); assert_eq!(conflict.part(ConflictPart::Base), Some(1..2)); assert_eq!(conflict.label(ConflictPart::Base).as_str(), "base"); assert_eq!(conflict.kept(DiffConflictResolution::Both), [0..1, 2..3]); @@ -286,6 +345,16 @@ mod tests { let error = DiffFile::parse_conflicts("a.txt", text).err().unwrap(); assert!(error.line() > 0); } + let empty = DiffFile::parse_conflicts( + "empty.txt", + "<<<<<<< ours\n||||||| base\n=======\n>>>>>>> theirs\n", + ) + .unwrap(); + let conflict = &empty.conflicts()[0]; + assert_eq!(conflict.lines(), 1..5); + assert_eq!(conflict.current_lines(), 2..2); + assert_eq!(conflict.base_lines(), Some(3..3)); + assert_eq!(conflict.incoming_lines(), 4..4); // A separator outside a conflict is ordinary text. assert!(DiffFile::parse_conflicts("a.md", "title\n=======\n").is_ok()); } diff --git a/crates/component/src/diff/document.rs b/crates/component/src/diff/document.rs index a640d977b7..ad21555d1b 100644 --- a/crates/component/src/diff/document.rs +++ b/crates/component/src/diff/document.rs @@ -2,7 +2,7 @@ use std::{ops::Range, sync::Arc}; use gpui::SharedString; -use super::conflict::Conflict; +use super::conflict::DiffConflict; use smallvec::{SmallVec, smallvec}; use unicode_segmentation::UnicodeSegmentation as _; @@ -377,7 +377,7 @@ struct DocumentInner { /// The patch body in order; each side's lines map back into it. patch_lines: Vec, patch_ixs: [Vec; 2], - conflicts: Vec, + conflicts: Vec, } /// One changed file parsed from a unified or Git diff. @@ -521,10 +521,11 @@ impl DiffFile { DiffFileStatus::Unchanged | DiffFileStatus::Conflicted ) } - pub(crate) fn conflicts(&self) -> &[Conflict] { + /// Merge conflicts in working-file order; slice indices identify resolutions. + pub fn conflicts(&self) -> &[DiffConflict] { &self.inner.conflicts } - pub(crate) fn with_conflicts(mut self, conflicts: Vec) -> Self { + pub(crate) fn with_conflicts(mut self, conflicts: Vec) -> Self { Arc::get_mut(&mut self.inner) .expect("a newly built file is not shared") .conflicts = conflicts; diff --git a/crates/component/src/diff/header_tests.rs b/crates/component/src/diff/header_tests.rs index 8480a252f2..9069f7a3d3 100644 --- a/crates/component/src/diff/header_tests.rs +++ b/crates/component/src/diff/header_tests.rs @@ -31,7 +31,7 @@ fn test_diff_builder(cx: &mut TestAppContext) { .line_number(false) .syntax_highlight(false) .annotations([annotation.clone()]) - .header({ + .render_header({ let header_calls = header_calls.clone(); move |document: &DiffFile, _, _| { header_calls.borrow_mut().push(( @@ -41,7 +41,7 @@ fn test_diff_builder(cx: &mut TestAppContext) { div().child("header") } }) - .annotation_content({ + .render_annotation({ let annotation_calls = annotation_calls.clone(); move |annotation, _, _| { assert_eq!(annotation.id(), &"review-comment".into()); @@ -60,6 +60,19 @@ fn test_diff_builder(cx: &mut TestAppContext) { assert_eq!(diff.annotations.items[0].position(), Some(&position)); assert_eq!(diff.annotations.lines[&position], [0]); + // Existing consumers keep the original builder spellings. + let compatibility = Diff::new(&state) + .header(|_, _, _| div().child("header")) + .header_prefix(|_, _, _| div().child("prefix")) + .header_title_suffix(|_, _, _| div().child("title suffix")) + .header_suffix(|_, _, _| div().child("suffix")) + .annotation_content(|_, _, _| div().child("annotation")); + assert!(compatibility.header.content.is_some()); + assert!(compatibility.header.prefix.is_some()); + assert!(compatibility.header.title_suffix.is_some()); + assert!(compatibility.header.suffix.is_some()); + assert!(compatibility.annotation_content.is_some()); + let window = cx.add_empty_window(); window.update(|window, cx| { let render = diff.header.content.as_ref().unwrap(); diff --git a/crates/component/src/diff/mod.rs b/crates/component/src/diff/mod.rs index 8084135516..09ae66999c 100644 --- a/crates/component/src/diff/mod.rs +++ b/crates/component/src/diff/mod.rs @@ -10,7 +10,7 @@ mod presentation; mod selection; mod state; -pub use conflict::DiffConflictResolution; +pub use conflict::{DiffConflict, DiffConflictResolution}; pub use document::{DiffFile, DiffFileStatus, DiffLinePosition, DiffLineRange, DiffSide}; pub use parser::DiffParseError; pub use presentation::DiffInlineUnit; @@ -19,7 +19,7 @@ pub use state::{DiffEvent, DiffState}; use std::{collections::HashMap, rc::Rc, sync::Arc}; use gpui::{ - AnyElement, App, ClickEvent, ClipboardItem, Context, ElementId, Entity, Focusable as _, + AnyElement, App, Axis, ClickEvent, ClipboardItem, Context, ElementId, Entity, Focusable as _, HighlightStyle, Hsla, InteractiveElement as _, IntoElement, KeyBinding, ListOffset, MouseButton, ParentElement as _, Pixels, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, TextRun, Window, div, list, point, @@ -33,7 +33,7 @@ use crate::{ button::{Button, ButtonVariants as _}, h_flex, input::{Copy, SelectAll}, - scroll::Scrollbar, + scroll::{ScrollableMask, Scrollbar}, v_flex, }; use actions::*; @@ -312,7 +312,7 @@ impl Diff { self } /// Renders each supplied annotation. Application state and commands stay with the owner. - pub fn annotation_content(mut self, render: F) -> Self + pub fn render_annotation(mut self, render: F) -> Self where F: Fn(&DiffAnnotation, &mut Window, &mut App) -> E + 'static, E: IntoElement, @@ -324,7 +324,7 @@ impl Diff { } /// Replaces the content of each file header, keeping its shell, separator /// and collapse control. - pub fn header(mut self, render: F) -> Self + pub fn render_header(mut self, render: F) -> Self where F: Fn(&DiffFile, &mut Window, &mut App) -> E + 'static, E: IntoElement, @@ -333,7 +333,7 @@ impl Diff { self } /// Inserts content before the path in the default header. - pub fn header_prefix(mut self, render: F) -> Self + pub fn render_header_prefix(mut self, render: F) -> Self where F: Fn(&DiffFile, &mut Window, &mut App) -> E + 'static, E: IntoElement, @@ -342,7 +342,7 @@ impl Diff { self } /// Inserts compact content immediately after the path in the default header. - pub fn header_title_suffix(mut self, render: F) -> Self + pub fn render_header_title_suffix(mut self, render: F) -> Self where F: Fn(&DiffFile, &mut Window, &mut App) -> E + 'static, E: IntoElement, @@ -351,7 +351,7 @@ impl Diff { self } /// Inserts trailing content after the change statistics in the default header. - pub fn header_suffix(mut self, render: F) -> Self + pub fn render_header_suffix(mut self, render: F) -> Self where F: Fn(&DiffFile, &mut Window, &mut App) -> E + 'static, E: IntoElement, @@ -359,6 +359,51 @@ impl Diff { self.header.suffix = Some(file_renderer(render)); self } + /// Compatibility alias for [`Self::render_annotation`]. + pub fn annotation_content(self, render: F) -> Self + where + F: Fn(&DiffAnnotation, &mut Window, &mut App) -> E + 'static, + E: IntoElement, + { + self.render_annotation(render) + } + + /// Compatibility alias for [`Self::render_header`]. + pub fn header(self, render: F) -> Self + where + F: Fn(&DiffFile, &mut Window, &mut App) -> E + 'static, + E: IntoElement, + { + self.render_header(render) + } + + /// Compatibility alias for [`Self::render_header_prefix`]. + pub fn header_prefix(self, render: F) -> Self + where + F: Fn(&DiffFile, &mut Window, &mut App) -> E + 'static, + E: IntoElement, + { + self.render_header_prefix(render) + } + + /// Compatibility alias for [`Self::render_header_title_suffix`]. + pub fn header_title_suffix(self, render: F) -> Self + where + F: Fn(&DiffFile, &mut Window, &mut App) -> E + 'static, + E: IntoElement, + { + self.render_header_title_suffix(render) + } + + /// Compatibility alias for [`Self::render_header_suffix`]. + pub fn header_suffix(self, render: F) -> Self + where + F: Fn(&DiffFile, &mut Window, &mut App) -> E + 'static, + E: IntoElement, + { + self.render_header_suffix(render) + } + /// Shows an add button beside the hovered line and at the end of the /// selected lines. The handler receives the selected range when the line /// is part of it, or that line alone. @@ -465,6 +510,7 @@ fn render_diff( font_size, row_height, gutter_width, + rem, expansion_lines: state.expansion_lines(), line_number: props.line_number, syntax_highlight: props.syntax_highlight, @@ -504,7 +550,9 @@ fn render_diff( div() .id("diff-horizontal") .size_full() - .when(!soft_wrap, |this| this.overflow_x_scroll()) + // Preserve offset tracking and clipping without GPUI's + // single-axis wheel remapping. The sibling mask owns X input. + .when(!soft_wrap, |this| this.overflow_x_hidden()) .track_scroll(&horizontal) .child( list(scrollbar.clone(), move |ix, window, cx| { @@ -524,6 +572,11 @@ fn render_diff( state.selection().clone(), state.geometry().clone(), )) + .when(has_rows && !soft_wrap, |this| { + this.child( + ScrollableMask::new(Axis::Horizontal, &horizontal).id("diff-horizontal-wheel"), + ) + }) .when(has_rows, |this| { this.child(Scrollbar::vertical(&scrollbar)) .when(!soft_wrap, |this| { @@ -690,6 +743,7 @@ struct CodePresentation { font_size: Pixels, row_height: Pixels, gutter_width: Pixels, + rem: Pixels, expansion_lines: usize, line_number: bool, syntax_highlight: bool, @@ -1305,7 +1359,8 @@ fn render_cell( this.border_r_1().border_color(cx.theme().border) }) .when(ix.is_none(), |this| { - this.bg(cx.theme().muted.opacity(0.3)).child(div().h_6()) + this.bg(cx.theme().muted.opacity(0.3)) + .child(div().h(code.row_height)) }); let Some(ix) = ix else { return cell.into_any_element(); @@ -1511,7 +1566,7 @@ fn code_line( document .conflicts() .iter() - .find(|conflict| conflict.lines().contains(&ix)) + .find(|conflict| conflict.source_lines().contains(&ix)) .and_then(|conflict| conflict.part_of(ix)) }) .flatten(); @@ -1603,12 +1658,14 @@ fn code_line( code.hover_highlight, DiffHoverHighlight::Line | DiffHoverHighlight::Both ); - let add_button = add_annotation_button(file, side, ix, gutters_width, &code, cx); + let add_button = + add_annotation_button(file, side, ix, gutters_width + code.rem / 2., &code, cx); h_flex() .id("line-row") .group("diff-line") .relative() .w_full() + .px_2() .map(|this| { if code.soft_wrap { this.min_h(code.row_height) diff --git a/crates/component/src/diff/review_tests.rs b/crates/component/src/diff/review_tests.rs index b0f7e60057..f3ae7577a2 100644 --- a/crates/component/src/diff/review_tests.rs +++ b/crates/component/src/diff/review_tests.rs @@ -327,4 +327,46 @@ fn builders_after_new_shape_the_prepared_emphasis(cx: &mut TestAppContext) { let whole_lines = cx.new(|cx| DiffState::new(DiffFile::parse(patch).unwrap(), cx).with_inline_unit(None)); assert_eq!(inline_count(&whole_lines, cx), 0); + // Runtime changes invalidate earlier work, including an in-flight preparation. + words.update(cx, |state, cx| { + state.set_inline_unit(None, cx); + state.set_inline_unit(Some(super::DiffInlineUnit::Character), cx); + }); + assert!(inline_count(&words, cx) > 0); + words.update(cx, |state, cx| state.set_inline_max_line_length(5, cx)); + assert_eq!(inline_count(&words, cx), 0); + words.update(cx, |state, cx| { + state.set_inline_max_line_length(2048, cx); + state.set_inline_unit(Some(super::DiffInlineUnit::Word), cx); + }); + assert_eq!(inline_count(&words, cx), 1); +} + +#[gpui::test] +fn changing_presentation_options_keeps_selection_and_scroll(cx: &mut TestAppContext) { + let state = cx.new(|cx| DiffState::new(DiffFile::parse(PATCH).unwrap(), cx)); + let (events, _subscription) = record_events(cx, &state); + state.update(cx, |state, cx| { + let range = DiffLineRange::new("second.txt", DiffSide::Modified, 10, 10); + state.set_selected_lines(Some(range.clone()), cx); + state.scroll_to_line(range.start_position(), cx); + let top = state.list().logical_scroll_top(); + state.set_inline_unit(Some(super::DiffInlineUnit::Character), cx); + state.set_inline_max_line_length(2048, cx); + state.set_syntax_max_line_length(4096, cx); + state.set_expansion_lines(5, cx); + state.set_min_collapsed_lines(4, cx); + assert_eq!(state.selected_lines(), Some(range)); + assert_eq!(state.list().logical_scroll_top().item_ix, top.item_ix); + assert_eq!(state.inline_unit(), Some(super::DiffInlineUnit::Character)); + assert_eq!(state.inline_max_line_length(), 2048); + assert_eq!(state.syntax_max_line_length(), 4096); + assert_eq!(state.expansion_lines(), 5); + assert_eq!(state.min_collapsed_lines(), 4); + // None disables emphasis, without replacing the entity or patch. + state.set_inline_unit(None, cx); + assert!(state.inline_unit().is_none()); + assert_eq!(state.files().len(), 2); + }); + assert!(events.borrow().is_empty()); } diff --git a/crates/component/src/diff/state.rs b/crates/component/src/diff/state.rs index 21b8ef367e..7a8ddf5155 100644 --- a/crates/component/src/diff/state.rs +++ b/crates/component/src/diff/state.rs @@ -312,9 +312,12 @@ impl DiffState { } } TextSelectionEvent::Cleared => { - // Mouse-down cleanup precedes gutter activation. Retain - // its anchor for Shift-click, without an intermediate event. - state.selected_lines = None; + // A replacement gutter gesture has local selection and + // skips this branch. Keep its anchor for Shift-click, + // but report a clear that leaves no replacement range. + if state.selected_lines.take().is_some() { + cx.emit(DiffEvent::SelectionChanged(None)); + } } _ => {} } @@ -420,6 +423,60 @@ impl DiffState { self } + /// Changes the number of lines each disclosure reveals, preserving selection and scrolling. + pub fn set_expansion_lines(&mut self, lines: usize, cx: &mut Context) { + let lines = lines.max(1); + if self.expansion_lines == lines { + return; + } + self.expansion_lines = lines; + cx.notify(); + } + + /// Changes the shortest unchanged range that collapses, preserving selection and scrolling. + pub fn set_min_collapsed_lines(&mut self, lines: usize, cx: &mut Context) { + let lines = lines.max(1); + if self.min_collapsed_lines == lines { + return; + } + self.min_collapsed_lines = lines; + self.rebuild_at_anchor(); + cx.notify(); + } + + /// Changes the inline comparison unit without resetting selection or scrolling. + pub fn set_inline_unit(&mut self, unit: Option, cx: &mut Context) { + if self.presentation_options.inline_unit == unit { + return; + } + self.presentation_options.inline_unit = unit; + self.reset_presentation(); + self.ensure_presentation(cx); + cx.notify(); + } + + /// Changes the inline comparison line limit in bytes without resetting selection or scrolling. + pub fn set_inline_max_line_length(&mut self, length: usize, cx: &mut Context) { + if self.presentation_options.inline_max_line_length == length { + return; + } + self.presentation_options.inline_max_line_length = length; + self.reset_presentation(); + self.ensure_presentation(cx); + cx.notify(); + } + + /// Changes the syntax emphasis line limit in bytes without resetting selection or scrolling. + pub fn set_syntax_max_line_length(&mut self, length: usize, cx: &mut Context) { + if self.presentation_options.syntax_max_line_length == length { + return; + } + self.presentation_options.syntax_max_line_length = length; + self.reset_presentation(); + self.ensure_presentation(cx); + cx.notify(); + } + /// The files in patch order. pub fn files(&self) -> &[DiffFile] { &self.files @@ -582,12 +639,15 @@ impl DiffState { } /// The resolution of conflict `ix` in the file at `path`, if any. + /// Use [`DiffFile::conflicts`] to enumerate valid indices; `None` means + /// unresolved, or that the file or index does not exist. pub fn conflict_resolution(&self, path: &str, ix: usize) -> Option { let file = &self.files[self.file_ix(path)?]; self.resolutions.get(&(file.path().clone(), ix)).copied() } - /// Resolves conflict `ix` in the file at `path`, or clears its resolution + /// Resolves conflict `ix` (an index in [`DiffFile::conflicts`]) in the file + /// at `path`, or clears its resolution /// with `None`, without emitting a user event. pub fn resolve_conflict( &mut self, @@ -626,7 +686,7 @@ impl DiffState { let mut ix = 0; for (conflict_ix, conflict) in file.conflicts().iter().enumerate() { let resolution = *self.resolutions.get(&(file.path().clone(), conflict_ix))?; - for line in &lines[ix..conflict.lines().start] { + for line in &lines[ix..conflict.source_lines().start] { text.push_str(&source[line.source()]); } for range in conflict.kept(resolution) { @@ -634,7 +694,7 @@ impl DiffState { text.push_str(&source[line.source()]); } } - ix = conflict.lines().end; + ix = conflict.source_lines().end; } for line in &lines[ix..] { text.push_str(&source[line.source()]); @@ -1613,7 +1673,7 @@ fn project_conflicts( let mut ix = 0; while ix < visible.len() { if let Some(conflict) = conflicts.get(next) - && conflict.lines().start == ix + && conflict.source_lines().start == ix { match resolutions[next] { None => { @@ -1643,7 +1703,7 @@ fn project_conflicts( } } } - ix = conflict.lines().end; + ix = conflict.source_lines().end; next += 1; continue; } diff --git a/crates/kit/tests/diff.rs b/crates/kit/tests/diff.rs index d8fb2d5447..da05d94563 100644 --- a/crates/kit/tests/diff.rs +++ b/crates/kit/tests/diff.rs @@ -5,16 +5,16 @@ mod common; use gpui_kit::component::{ button::Button, diff::{ - Diff, DiffAnnotation, DiffFile, DiffLinePosition, DiffLineRange, DiffMode, DiffSide, - DiffState, + Diff, DiffAnnotation, DiffEvent, DiffFile, DiffLinePosition, DiffLineRange, DiffMode, + DiffSide, DiffState, }, input::{Input, InputState}, }; use gpui_kit::test::{TestSupportExt as _, TestWindowExt as _}; use gpui_kit::{ App, AppContext, Context, Entity, Focusable as _, InputEvent as _, Modifiers, MouseButton, - MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, Role, TestAppContext, Window, - WindowHandle, div, point, prelude::*, px, size, + MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, Role, ScrollDelta, TestAppContext, + Window, WindowHandle, div, point, prelude::*, px, size, }; struct Review { @@ -39,7 +39,7 @@ impl Render for Review { .flex_1() .min_h_0() .annotations(self.annotations.clone()) - .annotation_content(move |_, _, _| { + .render_annotation(move |_, _, _| { div() .id("comment-content") .test_support() @@ -415,7 +415,7 @@ impl Render for Interactions { Diff::new(&self.state) .size_full() .annotations([DiffAnnotation::file("file-note", "review.txt")]) - .annotation_content(|annotation, _, _| { + .render_annotation(|annotation, _, _| { div() .id("note-content") .test_support() @@ -542,15 +542,17 @@ fn header_collapses_its_file_and_shows_file_annotations(cx: &mut TestAppContext) struct Viewer { state: Entity, soft_wrap: bool, + line_number: bool, } impl Render for Viewer { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - div() - .id("review") - .test_support() - .size_full() - .child(Diff::new(&self.state).soft_wrap(self.soft_wrap).size_full()) + div().id("review").test_support().size_full().child( + Diff::new(&self.state) + .soft_wrap(self.soft_wrap) + .line_number(self.line_number) + .size_full(), + ) } } @@ -565,6 +567,7 @@ fn viewer( cx.new(|cx| Viewer { state: cx.new(|cx| DiffState::new([file], cx).with_mode(mode)), soft_wrap, + line_number: true, }) }); let state = cx.update(|cx| view.read(cx).state.clone()); @@ -615,3 +618,145 @@ fn soft_wrap_grows_rows_instead_of_scrolling(cx: &mut TestAppContext) { }) .unwrap(); } + +#[gpui_kit::test] +fn clearing_gutter_selection_notifies_without_interrupting_shift_extension( + cx: &mut TestAppContext, +) { + let (handle, state) = review( + cx, + "@@ -1,3 +1,3 @@\n one\n-old\n+new\n three\n", + None, + DiffMode::Split, + vec![], + ); + let changes = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let _subscription = cx.update(|cx| { + let changes = changes.clone(); + cx.subscribe(&state, move |_, event: &DiffEvent, _| { + if let DiffEvent::SelectionChanged(range) = event { + changes.borrow_mut().push(range.clone()); + } + }) + }); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.within(("old", 0usize)).click(("line", 0usize), cx); + }) + .unwrap(); + assert_eq!(changes.borrow().len(), 1); + cx.update_window(handle.into(), |_, window, cx| { + let target = window + .within(("old", 2usize)) + .find(("line", 2usize)) + .bounds() + .center(); + shift_click(window, target, cx); + }) + .unwrap(); + assert_eq!(changes.borrow().len(), 2); + assert!(changes.borrow().iter().all(Option::is_some)); + cx.update_window(handle.into(), |_, window, cx| { + window.click("before-diff", cx); + }) + .unwrap(); + cx.update(|cx| assert!(state.read(cx).selected_lines().is_none())); + assert_eq!(changes.borrow().len(), 3); + assert!(changes.borrow().last().unwrap().is_none()); + cx.update_window(handle.into(), |_, window, cx| { + window.click("before-diff", cx); + }) + .unwrap(); + assert_eq!(changes.borrow().len(), 3); +} + +#[gpui_kit::test] +fn wheel_axes_stay_independent_when_code_overflows_in_both_directions(cx: &mut TestAppContext) { + let line = "let long_line = ".repeat(40); + let mut patch = String::from("@@ -1,60 +1,60 @@\n"); + for tag in ['-', '+'] { + for ix in 0..60 { + patch.push_str(&format!("{tag}{line}{ix}\n")); + } + } + for mode in [DiffMode::Unified, DiffMode::Split] { + let (handle, _) = review(cx, &patch, None, mode, vec![]); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + let before = window.within(("old", 10usize)).find("source").bounds(); + window.scroll( + "diff-body", + ScrollDelta::Pixels(point(px(0.), px(-100.))), + cx, + ); + let vertical = window.within(("old", 10usize)).find("source").bounds(); + assert!(vertical.top() < before.top()); + assert_eq!(vertical.left(), before.left()); + window.scroll( + "diff-body", + ScrollDelta::Pixels(point(px(-100.), px(0.))), + cx, + ); + let horizontal = window.within(("old", 10usize)).find("source").bounds(); + assert!(horizontal.left() < vertical.left()); + assert_eq!(horizontal.top(), vertical.top()); + }) + .unwrap(); + } +} + +#[gpui_kit::test] +fn hidden_line_numbers_keep_wrapped_source_aligned_and_selectable(cx: &mut TestAppContext) { + cx.update(gpui_kit::init); + let long = "changed word ".repeat(24); + let patch = format!("@@ -1,3 +1,4 @@\n keep\n-old\n+{long}\n+extra\n tail\n"); + let file = patch_document(&patch); + let (handle, view) = common::open_window(cx, Some(size(px(480.), px(480.))), |_, cx| { + cx.new(|cx| Viewer { + state: cx.new(|cx| DiffState::new([file], cx).with_mode(DiffMode::Split)), + soft_wrap: true, + line_number: false, + }) + }); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.render_frame(cx); + let keep = window.within(("new", 0usize)).find("source").bounds(); + let changed = window.within(("new", 1usize)).find("source").bounds(); + let extra = window.within(("new", 2usize)).find("source").bounds(); + let viewport = window.find("review").bounds(); + assert!(keep.left() > viewport.left() + viewport.size.width / 2. + window.rem_size()); + assert!(keep.right() < viewport.right()); + assert_eq!(keep.left(), changed.left()); + assert_eq!(changed.left(), extra.left()); + assert!(changed.size.height > keep.size.height * 2.); + assert!(extra.top() >= changed.bottom()); + window.within(("new", 2usize)).click("source", cx); + window.press( + if cfg!(target_os = "macos") { + "cmd-a" + } else { + "ctrl-a" + }, + cx, + ); + let state = view.read(cx).state.clone(); + let expected = format!("keep\n{long}\nextra\ntail\n"); + assert_eq!(state.read(cx).selected_text(cx), expected); + view.update(cx, |view, cx| { + view.line_number = true; + cx.notify(); + }); + window.render_frame(cx); + assert!( + window + .within(("new", 0usize)) + .find("source") + .bounds() + .left() + > keep.left() + ); + assert_eq!(state.read(cx).selected_text(cx), expected); + }) + .unwrap(); +} diff --git a/crates/story/src/stories/diff_story.rs b/crates/story/src/stories/diff_story.rs index be68804eb1..6fb02d2b27 100644 --- a/crates/story/src/stories/diff_story.rs +++ b/crates/story/src/stories/diff_story.rs @@ -385,7 +385,7 @@ impl Render for DiffStory { .soft_wrap(self.wrap) .when(self.example == 0, |diff| { diff.annotations(annotations) - .annotation_content(move |annotation, _, cx| { + .render_annotation(move |annotation, _, cx| { let (id, body, resolved) = comments .iter() .find(|(id, ..)| id == annotation.id()) diff --git a/examples/tig/Cargo.toml b/examples/tig/Cargo.toml new file mode 100644 index 0000000000..7b1898b917 --- /dev/null +++ b/examples/tig/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "example-tig" +description = "A small desktop Git history browser built with GPUI Kit's Diff." +version = "0.7.1" +publish = false +edition.workspace = true + +[dependencies] +gpui-kit = { workspace = true, features = ["tree-sitter-rust", "tree-sitter-toml", "tree-sitter-markdown", "tree-sitter-javascript", "tree-sitter-typescript"] } + +[dev-dependencies] +gpui-kit = { workspace = true, features = ["test-support"] } + +[features] +gpui-fast = ["gpui-kit/gpui-fast"] + +[lints] +workspace = true diff --git a/examples/tig/README.md b/examples/tig/README.md new file mode 100644 index 0000000000..8c3f12712a --- /dev/null +++ b/examples/tig/README.md @@ -0,0 +1,111 @@ +# Git history + +A small desktop Git browser inspired by tig. Browse the latest 200 commits +reachable from HEAD, inspect each file in `Diff`, and jump between the commit’s +changed files. Commit and file navigation share a resizable sidebar; the diff +owns the main work area. The commit body opens from the disclosure beside the subject. Dark appearance and +Split layout are the defaults. + +```sh +cargo run -p example-tig -- . +cargo run -p example-tig -- /path/to/repository +cargo run -p example-tig --features gpui-fast -- . +``` + +Git must be on PATH. The argument defaults to the current directory and may +point inside a working tree or to a bare repository. No repository files are +written. This example browses committed history; it does not stage changes, +checkout branches, edit source, or show uncommitted work. + +## Using it + +Select a commit in **Commits**, then a file in **Changed files**. The viewport +shows only the selected file, so its header and source always share the same +scope. Selecting a file again reveals its header and expands it if collapsed. +The selected commit shows its subject, author, date and short hash above the +diff. The copy button next to the hash copies the full object ID. +The disclosure beside the subject reveals the remaining commit message in a +separate, scrollable region without displacing the code by default. + +**Diff display options → Unified / Split** changes the layout while retaining source selection. +Use the arrow buttons or n / Shift+N to navigate change groups in the selected file. +The settings icon opens **Diff display options**, containing +wrapping, line numbers, inline word/grapheme emphasis and context expansion. Its Appearance submenu switches between Light and Dark. +These choices update the retained state rather than replacing it. Long lines +wrap by default. Diff retains its themed added/deleted line backgrounds, gutter +signs and inline emphasis so changes remain easy to scan. + +| Context | Key | Result | +| --- | --- | --- | +| Commits | ↑ / ↓ or k / j | Select the newer / older commit | +| Commits | Enter | Focus the diff | +| Changed files | ↑ / ↓ or k / j | Reveal the previous / next file | +| Changed files | Enter | Focus the diff | +| Anywhere | [ / ] | Reveal the previous / next file | +| Anywhere | Escape | Show the sidebar and return to commit history | +| Anywhere | Cmd+B / Ctrl+B | Show / hide the sidebar | +| Anywhere | n / Shift+N | Reveal the next / previous change | +| Anywhere | Cmd+R / Ctrl+R | Refresh history, retaining the selected commit when available | +| Anywhere | Cmd+Shift+C / Ctrl+Shift+C | Copy the commit's full hash | +| Diff | Cmd+C / Ctrl+C | Copy selected source | +| Anywhere | Cmd+Q / Ctrl+Q | Quit | + +Tab moves between command buttons, the history, files and the diff. Vertical +wheel input scrolls source rows; horizontal gestures scroll long lines. The +sidebar's two sections and its width can be resized independently. The status +bar offers a sidebar toggle. Clicking a file keeps focus in the file list; +Enter moves into source. Keyboard focus is shown on the active row rather than +around the whole navigation pane. Shortcut tooltips and status hints use `Kbd` +resolved from the actual Actions. The status bar reports the commit/file position +and the current source selection. + +## Input and state + +`repository.rs` runs `git log` and `git show` on the background executor. Log +records use NUL delimiters so tabs, Unicode and empty commit subjects remain +valid. The patch is parsed into `DiffFile`s off the UI thread. Merge commits +are compared to their first parent, producing an ordinary two-sided patch. +Root commits, renames, deletions, binary summaries and metadata-only changes +use the same parser as other Diff applications. + +`Tig` owns the commit list, selection, focus handles, two virtualized navigation +lists and one `Entity`. Changing commits clears old source immediately. +Each request carries a revision: a result from an earlier selection cannot +overwrite the current one. A refresh retains the selected hash when it is +still in the history window. + +Only context supplied by Git is available. Expanding a fold cannot recover +missing lines. There is no source comparison or repository access inside +`Diff` itself. Non-UTF-8 patch output produces an error instead of silently +changing the source. Empty repositories and commits without file changes have +distinct empty states; read errors offer **Retry**. + +To check repository loading without opening a window: + +```sh +cargo run -p example-tig -- --check /path/to/repository +``` + +The example includes parser regression tests and production-view UI integration +tests for commit/file navigation, startup file selection, stale-result rejection, +focus and independent commit-message scrolling. + +## Showcase + +Open a real Rust change at a reproducible position: + +```sh +cargo run -p example-tig -- --commit 4890b1c2 --file crates/component/src/speech/waveform.rs --line 70 . +``` + +`--commit` accepts a 7–64 character hexadecimal object ID and starts history +from that commit. `--file` selects a changed file; `--line` reveals a one-based +modified-side source line and requires `--file`. These startup choices do not +restrict subsequent navigation. + +![Dark Split view with syntax highlighting, wrapped rows and inline changes](screenshots/split-dark.png) + +Earlier screenshots remain available: [initial preview](screenshots/before.png) +and [intermediate preview](screenshots/unified.png). + +[中文说明](README.zh-CN.md) diff --git a/examples/tig/README.zh-CN.md b/examples/tig/README.zh-CN.md new file mode 100644 index 0000000000..0f3a17df0f --- /dev/null +++ b/examples/tig/README.zh-CN.md @@ -0,0 +1,89 @@ +# Git history + +一个受 tig 启发的桌面 Git 浏览器。浏览 HEAD 可达的最近 200 条提交,在 `Diff` 中查看 +每个文件的变更,并在当前提交修改的文件之间跳转。提交和文件导航共用可调整宽度的侧栏, +Diff 占据主要工作区。提交正文默认收起,可通过标题后的展开按钮查看。默认使用 Dark 主题和 Split 布局。 + +```sh +cargo run -p example-tig -- . +cargo run -p example-tig -- /path/to/repository +cargo run -p example-tig --features gpui-fast -- . +``` + +Git 必须在 PATH 中。路径参数默认为当前目录,也可以指向工作区内的目录或裸仓库。 +示例不写入仓库文件。它浏览已提交的历史,不包含暂存、切换分支、编辑源文件或显示未提交改动。 + +## 使用方式 + +先在 **Commits** 中选择提交,再在 **Changed files** 中选择文件。视口只显示选中的文件, +确保文件头与源文本始终属于同一个对象。再次选择当前文件会滚动到文件头,若文件已收起则先展开。Diff 上方显示当前提交的标题、作者、日期和短哈希。 +哈希旁的复制按钮复制完整的对象 ID;标题后的展开按钮显示提交正文;正文有独立的滚动区域,避免长正文默认挤占代码空间。 + +在 **Diff display options → Unified / Split** 中切换布局并保留源文本选区。使用箭头按钮或 n / Shift+N 在当前文件的变更组之间跳转。 +设置图标打开 **Diff display options**,集中提供换行、行号、单词或字素强调,以及上下文展开选项。 +Appearance 子菜单可切换 Light 和 Dark 主题。 +这些选项更新已有状态,不重新创建状态实体。长行默认换行;保留 Diff 的主题化增删行背景, +配合 gutter 中的符号和行内强调区分变更。 + +| 范围 | 按键 | 结果 | +| --- | --- | --- | +| Commits | ↑ / ↓ 或 k / j | 选择更新/更早的提交 | +| Commits | Enter | 聚焦 Diff | +| Changed files | ↑ / ↓ 或 k / j | 显示上一个/下一个文件 | +| Changed files | Enter | 聚焦 Diff | +| 任意区域 | [ / ] | 显示上一个/下一个文件 | +| 任意区域 | Escape | 展开侧栏并返回提交历史 | +| 任意区域 | Cmd+B / Ctrl+B | 显示/隐藏侧栏 | +| 任意区域 | n / Shift+N | 显示下一处/上一处变更 | +| 任意区域 | Cmd+R / Ctrl+R | 刷新历史,当前提交仍可用时保留选择 | +| 任意区域 | Cmd+Shift+C / Ctrl+Shift+C | 复制提交的完整哈希 | +| Diff | Cmd+C / Ctrl+C | 复制选中的源文本 | +| 任意区域 | Cmd+Q / Ctrl+Q | 退出 | + +通过 Tab 在按钮、历史、文件和 Diff 之间移动。垂直滚轮滚动源文本行,水平手势滚动长行。 +侧栏的两个分区和整个侧栏的宽度都可以独立调整,状态栏左侧提供侧栏折叠按钮。 +点击文件后焦点保留在文件列表,Enter 才进入源文本。键盘焦点显示在当前行, +不再框住整个导航面板。快捷键 tooltip 和状态提示通过 `Kbd` 从实际 Action 绑定中读取。 +状态栏显示当前提交、文件的位置及源文本选区。 + +## 输入与状态 + +`repository.rs` 在后台执行器中运行 `git log` 和 `git show`。提交记录用 NUL 分隔, +保留制表符、Unicode 和空提交标题。patch 也在 UI 线程之外解析为 `DiffFile`。 +merge commit 与第一父提交比较,生成普通的两侧 patch。根提交、重命名、删除、二进制摘要 +和仅元信息的变更使用同一个 Diff parser。 + +`Tig` 持有提交列表、选择、焦点句柄、两个虚拟化导航列表和一个 `Entity`。 +切换提交立即清空旧源文本。每次请求携带一个版本号:早先选择的结果不会覆盖当前提交。 +刷新后,若原提交的哈希仍在历史窗口内,就保留原选择。 + +只有 Git 提供的上下文可用。展开折叠不能恢复未提供的行。`Diff` 本身不比较完整源文件, +也不访问仓库。非 UTF-8 的 patch 输出会显示错误,不会静默修改源文本。 +空仓库和没有文件变更的提交分别显示对应空状态,读取失败时提供 **Retry**。 + +不打开窗口检查仓库读取: + +```sh +cargo run -p example-tig -- --check /path/to/repository +``` + +示例包含 parser 回归测试,以及使用实际视图的 UI integration tests,覆盖提交和文件导航、 +启动文件定位、过期结果拒绝、焦点切换,以及提交正文的独立滚动。 + +## 演示 + +定位到一个真实的 Rust 变更,复现截图中的位置: + +```sh +cargo run -p example-tig -- --commit 4890b1c2 --file crates/component/src/speech/waveform.rs --line 70 . +``` + +`--commit` 接受 7–64 位十六进制对象 ID,从该提交开始读取历史。`--file` 选中一个变更文件; +`--line` 定位到修改侧从 1 开始的源文本行号,必须与 `--file` 一起使用。 +这些启动参数不会限制后续导航。 + +![Dark 主题的 Split 视图:语法高亮、换行和行内变更](screenshots/split-dark.png) + +早期截图也已保留:[最初预览](screenshots/before.png) 和 [中间版本](screenshots/unified.png)。 + +[English](README.md) diff --git a/examples/tig/screenshots/before.png b/examples/tig/screenshots/before.png new file mode 100644 index 0000000000..c1406a3dc5 Binary files /dev/null and b/examples/tig/screenshots/before.png differ diff --git a/examples/tig/screenshots/split-dark.png b/examples/tig/screenshots/split-dark.png new file mode 100644 index 0000000000..729392d899 Binary files /dev/null and b/examples/tig/screenshots/split-dark.png differ diff --git a/examples/tig/screenshots/unified.png b/examples/tig/screenshots/unified.png new file mode 100644 index 0000000000..6fb18c5f99 Binary files /dev/null and b/examples/tig/screenshots/unified.png differ diff --git a/examples/tig/src/app.rs b/examples/tig/src/app.rs new file mode 100644 index 0000000000..38fe3b7c6b --- /dev/null +++ b/examples/tig/src/app.rs @@ -0,0 +1,1293 @@ +use std::sync::Arc; + +use gpui_kit::base::TestSupportExt as _; +use gpui_kit::component::{ + ActiveTheme as _, Disableable as _, Icon, IconName, InteractiveElementExt as _, + Selectable as _, Sizable as _, StyledExt as _, Theme, ThemeMode, TitleBar, WindowExt as _, + button::{Button, ButtonVariants as _}, + diff::{ + Diff, DiffEvent, DiffFile, DiffFileStatus, DiffInlineUnit, DiffLinePosition, DiffMode, + DiffSide, DiffState, + }, + h_flex, + kbd::Kbd, + list::ListItem, + menu::DropdownMenu as _, + resizable::{h_resizable, resizable_panel, v_resizable}, + scroll::{ScrollableElement as _, Scrollbar}, + spinner::Spinner, + status_bar::StatusBar, + text::TextView, + tooltip::Tooltip, + v_flex, +}; +use gpui_kit::{ + Anchor, AnyElement, App, AppContext as _, ClipboardItem, Context, Entity, FocusHandle, + Focusable as _, InteractiveElement as _, IntoElement, KeyBinding, ParentElement as _, Render, + ScrollHandle, ScrollStrategy, SharedString, StatefulInteractiveElement as _, Styled as _, + Subscription, Task, UniformListScrollHandle, Window, div, prelude::FluentBuilder as _, rems, + uniform_list, +}; + +use super::repository::{Commit, CommitDetails, Repository}; + +mod commands { + gpui_kit::actions!( + tig, + [ + OlderCommit, + NewerCommit, + NextFile, + PreviousFile, + FocusDiff, + FocusHistory, + NextChange, + PreviousChange, + Refresh, + CopyHash, + ToggleWrap, + ToggleNumbers, + ToggleMessage, + ToggleSidebar, + UnifiedMode, + SplitMode, + LightAppearance, + DarkAppearance, + Words, + Characters, + WholeLines, + ThreeContext, + AllContext, + ExpandContext, + CollapseContext, + Quit + ] + ); +} +use commands::*; + +pub(super) fn init(cx: &mut App) { + cx.bind_keys([ + KeyBinding::new("down", OlderCommit, Some("TigHistory")), + KeyBinding::new("j", OlderCommit, Some("TigHistory")), + KeyBinding::new("up", NewerCommit, Some("TigHistory")), + KeyBinding::new("k", NewerCommit, Some("TigHistory")), + KeyBinding::new("enter", FocusDiff, Some("TigHistory")), + KeyBinding::new("down", NextFile, Some("TigFiles")), + KeyBinding::new("up", PreviousFile, Some("TigFiles")), + KeyBinding::new("j", NextFile, Some("TigFiles")), + KeyBinding::new("k", PreviousFile, Some("TigFiles")), + KeyBinding::new("enter", FocusDiff, Some("TigFiles")), + KeyBinding::new("]", NextFile, Some("Tig")), + KeyBinding::new("[", PreviousFile, Some("Tig")), + KeyBinding::new("escape", FocusHistory, Some("Tig")), + KeyBinding::new("n", NextChange, Some("Tig")), + KeyBinding::new("shift-n", PreviousChange, Some("Tig")), + KeyBinding::new("secondary-b", ToggleSidebar, Some("Tig")), + KeyBinding::new("secondary-r", Refresh, Some("Tig")), + KeyBinding::new("secondary-shift-c", CopyHash, Some("Tig")), + KeyBinding::new("secondary-q", Quit, Some("Tig")), + ]); +} + +pub(super) struct Tig { + repository: Repository, + commits: Arc>, + files: Arc>, + selected: Option, + selected_file: Option, + initial_file: Option, + initial_line: Option, + diff: Entity, + message: SharedString, + message_scroll: ScrollHandle, + history_focus: FocusHandle, + history_scroll: UniformListScrollHandle, + files_focus: FocusHandle, + files_scroll: UniformListScrollHandle, + wrap: bool, + line_numbers: bool, + message_visible: bool, + sidebar_visible: bool, + loading_history: bool, + loading_commit: bool, + error: Option, + revision: usize, + _history_task: Task<()>, + _commit_task: Task<()>, + _subscriptions: Vec, +} + +impl Tig { + pub(super) fn new(repository: Repository, window: &mut Window, cx: &mut Context) -> Self { + window.set_window_title("Git history — GPUI Kit"); + Theme::change(ThemeMode::Dark, Some(window), cx); + let diff = cx.new(|cx| DiffState::new([], cx).with_mode(DiffMode::Split)); + let history_focus = cx.focus_handle().tab_stop(true); + history_focus.focus(window, cx); + let files_focus = cx.focus_handle().tab_stop(true); + let subscriptions = vec![ + cx.on_focus(&history_focus, window, |_, _, cx| cx.notify()), + cx.on_blur(&history_focus, window, |_, _, cx| cx.notify()), + cx.on_focus(&files_focus, window, |_, _, cx| cx.notify()), + cx.on_blur(&files_focus, window, |_, _, cx| cx.notify()), + cx.observe(&diff, |_, _, cx| cx.notify()), + cx.subscribe(&diff, |this, _, event: &DiffEvent, cx| { + if let DiffEvent::SelectionChanged(Some(range)) = event { + this.selected_file = Some(range.path().clone()); + cx.notify(); + } + }), + ]; + let mut this = Self { + repository, + commits: Arc::default(), + files: Arc::default(), + selected: None, + selected_file: None, + initial_file: None, + initial_line: None, + diff, + message: SharedString::default(), + message_scroll: ScrollHandle::new(), + history_focus, + history_scroll: UniformListScrollHandle::new(), + files_focus, + files_scroll: UniformListScrollHandle::new(), + wrap: true, + line_numbers: true, + message_visible: false, + sidebar_visible: true, + loading_history: false, + loading_commit: false, + error: None, + revision: 0, + _history_task: Task::ready(()), + _commit_task: Task::ready(()), + _subscriptions: subscriptions, + }; + this.refresh(cx); + this + } + + pub(super) fn with_initial_file( + mut self, + file: Option, + line: Option, + ) -> Self { + self.initial_file = file; + self.initial_line = line; + self + } + + fn selected_commit(&self) -> Option<&Commit> { + self.commits.get(self.selected?) + } + + fn refresh(&mut self, cx: &mut Context) { + let previous = self.selected_commit().map(|commit| commit.hash.clone()); + self.revision += 1; + let revision = self.revision; + self._commit_task = Task::ready(()); + self.loading_history = true; + self.commits = Arc::default(); + self.selected = None; + self.loading_commit = false; + self.error = None; + // Clear the old source immediately: copy and file navigation must not + // act on a patch that belongs to the previous selection. + self.diff.update(cx, |state, cx| state.set_files([], cx)); + self.selected_file = None; + self.files = Arc::default(); + self.message = SharedString::default(); + self.message_scroll.set_offset(Default::default()); + let repository = self.repository.clone(); + self._history_task = cx.spawn(async move |this, cx| { + let result = cx + .background_spawn(async move { repository.history() }) + .await; + let _ = this.update(cx, |this, cx| { + if this.revision != revision { + return; + } + this.loading_history = false; + match result { + Ok(commits) => { + let selected = previous + .and_then(|hash| commits.iter().position(|commit| commit.hash == hash)) + .unwrap_or(0); + this.commits = Arc::new(commits); + this.selected = None; + if !this.commits.is_empty() { + this.select_commit(selected, cx); + } + } + Err(error) => this.error = Some(error), + } + cx.notify(); + }); + }); + cx.notify(); + } + + fn select_commit(&mut self, ix: usize, cx: &mut Context) { + if self.loading_history || self.commits.get(ix).is_none() || self.selected == Some(ix) { + return; + } + self.selected = Some(ix); + self.history_scroll.scroll_to_item(ix, ScrollStrategy::Top); + self.revision += 1; + let revision = self.revision; + self.loading_commit = true; + self.message_visible = false; + self.message_scroll.set_offset(Default::default()); + self.error = None; + self.message = SharedString::default(); + self.selected_file = None; + self.files = Arc::default(); + self.diff.update(cx, |state, cx| state.set_files([], cx)); + let repository = self.repository.clone(); + let hash = self.commits[ix].hash.clone(); + self._commit_task = cx.spawn(async move |this, cx| { + let result = cx + .background_spawn(async move { repository.details(&hash) }) + .await; + let _ = this.update(cx, |this, cx| this.install_commit(revision, result, cx)); + }); + cx.notify(); + } + + fn install_commit( + &mut self, + revision: usize, + result: Result, + cx: &mut Context, + ) { + if self.revision != revision { + return; + } + self.loading_commit = false; + match result { + Ok(details) => { + self.message = details.message; + self.files = Arc::new(details.files); + let ix = if let Some(path) = self.initial_file.take() { + let Some(ix) = self.files.iter().position(|file| file.path() == &path) else { + self.error = Some(format!("File {path} is not changed in this commit.")); + self.initial_line = None; + cx.notify(); + return; + }; + ix + } else { + 0 + }; + self.load_file(ix, cx); + if let Some(line) = self.initial_line.take() { + if let Some(path) = self.selected_file.clone() { + self.diff.update(cx, |state, cx| { + state.scroll_to_line( + DiffLinePosition::new(path, DiffSide::Modified, line), + cx, + ); + }); + } + } + } + Err(error) => self.error = Some(error), + } + cx.notify(); + } + + fn move_commit(&mut self, direction: isize, cx: &mut Context) { + if self.commits.is_empty() { + return; + } + let ix = self + .selected + .unwrap_or(0) + .saturating_add_signed(direction) + .min(self.commits.len() - 1); + self.select_commit(ix, cx); + } + + fn focus_diff(&self, window: &mut Window, cx: &mut Context) { + if !self.loading_history && !self.loading_commit && !self.diff.read(cx).files().is_empty() { + self.diff.read(cx).focus_handle(cx).focus(window, cx); + } + } + + fn load_file(&mut self, ix: usize, cx: &mut Context) { + let Some(file) = self.files.get(ix).cloned() else { + return; + }; + let path = file.path().clone(); + if self.selected_file.as_ref() == Some(&path) { + self.diff.update(cx, |state, cx| { + state.set_file_collapsed(&path, false, cx); + state.scroll_to_file(&path, cx); + }); + } else { + // One file owns the viewport. Its header remains a reliable scope + // when the user scrolls, rather than drifting into another file. + self.diff + .update(cx, |state, cx| state.set_files([file], cx)); + } + self.selected_file = Some(path); + self.files_scroll.scroll_to_item(ix, ScrollStrategy::Top); + cx.notify(); + } + + fn select_file(&mut self, path: SharedString, window: &mut Window, cx: &mut Context) { + if let Some(ix) = self.files.iter().position(|file| file.path() == &path) { + self.load_file(ix, cx); + self.files_focus.focus(window, cx); + } + } + + fn move_file(&mut self, direction: isize, cx: &mut Context) { + if self.files.is_empty() { + return; + } + let ix = self + .files + .iter() + .position(|file| Some(file.path()) == self.selected_file.as_ref()) + .unwrap_or(0) + .saturating_add_signed(direction) + .min(self.files.len() - 1); + self.load_file(ix, cx); + } + + fn toggle_sidebar(&mut self, window: &mut Window, cx: &mut Context) { + self.sidebar_visible = !self.sidebar_visible; + if !self.sidebar_visible + && (self.history_focus.is_focused(window) || self.files_focus.is_focused(window)) + { + self.diff.read(cx).focus_handle(cx).focus(window, cx); + } + cx.notify(); + } + + fn focus_history(&mut self, window: &mut Window, cx: &mut Context) { + self.sidebar_visible = true; + self.history_focus.focus(window, cx); + cx.notify(); + } + + fn toggle_message(&mut self, cx: &mut Context) { + if !self.message.is_empty() { + self.message_visible = !self.message_visible; + cx.notify(); + } + } + + fn copy_hash(&self, window: &mut Window, cx: &mut Context) { + if let Some(commit) = self.selected_commit() { + cx.write_to_clipboard(ClipboardItem::new_string(commit.hash.to_string())); + window.push_notification("Commit hash copied", cx); + } + } + + fn render_toolbar(&self, cx: &mut Context) -> AnyElement { + let dark = cx.theme().is_dark(); + let mode = self.diff.read(cx).mode(); + let context = self.diff.read(cx).context_lines(); + let inline = self.diff.read(cx).inline_unit(); + let (wrap, numbers) = (self.wrap, self.line_numbers); + let available = self + .diff + .read(cx) + .files() + .iter() + .any(|file| !file.is_binary() && (file.additions() > 0 || file.deletions() > 0)); + h_flex() + .flex_none() + .gap_2() + .child( + h_flex() + .gap_1() + .child( + Button::new("previous-change") + .ghost() + .small() + .icon(IconName::ArrowUp) + .accessibility_label("Previous change") + .tooltip_with_action("Previous change", &PreviousChange, Some("Tig")) + .disabled(!available) + .on_click(cx.listener(|this, _, _, cx| { + this.diff.update(cx, |state, cx| state.previous_change(cx)); + })), + ) + .child( + Button::new("next-change") + .ghost() + .small() + .icon(IconName::ArrowDown) + .accessibility_label("Next change") + .tooltip_with_action("Next change", &NextChange, Some("Tig")) + .disabled(!available) + .on_click(cx.listener(|this, _, _, cx| { + this.diff.update(cx, |state, cx| state.next_change(cx)); + })), + ), + ) + .child( + Button::new("display-options") + .outline() + .small() + .icon(IconName::Settings2) + .accessibility_label("Diff display options") + .tooltip("Diff display options") + .dropdown_menu_with_anchor(Anchor::TopRight, move |menu, window, cx| { + menu.menu_with_check( + "Unified", + mode == DiffMode::Unified, + Box::new(UnifiedMode), + ) + .menu_with_check("Split", mode == DiffMode::Split, Box::new(SplitMode)) + .separator() + .submenu("Appearance", window, cx, move |menu, _, _| { + menu.menu_with_check("Light", !dark, Box::new(LightAppearance)) + .menu_with_check("Dark", dark, Box::new(DarkAppearance)) + }) + .separator() + .menu_with_check("Wrap long lines", wrap, Box::new(ToggleWrap)) + .menu_with_check("Line numbers", numbers, Box::new(ToggleNumbers)) + .separator() + .submenu("Inline changes", window, cx, move |menu, _, _| { + menu.menu_with_check( + "Words", + inline == Some(DiffInlineUnit::Word), + Box::new(Words), + ) + .menu_with_check( + "Characters", + inline == Some(DiffInlineUnit::Character), + Box::new(Characters), + ) + .menu_with_check( + "Whole lines", + inline.is_none(), + Box::new(WholeLines), + ) + }) + .submenu( + "Context", + window, + cx, + move |menu, _, _| { + menu.menu_with_check( + "Three lines", + context == Some(3), + Box::new(ThreeContext), + ) + .menu_with_check( + "All supplied lines", + context.is_none(), + Box::new(AllContext), + ) + .separator() + .menu("Expand unchanged lines", Box::new(ExpandContext)) + .menu("Collapse unchanged lines", Box::new(CollapseContext)) + }, + ) + }), + ) + .into_any_element() + } + + fn render_history(&self, window: &Window, cx: &mut Context) -> AnyElement { + let selected = self.selected; + let commits = self.commits.clone(); + let owner = cx.entity().downgrade(); + let focused = self.history_focus.is_focused(window) && window.last_input_was_keyboard(); + v_flex() + .size_full() + .min_h_0() + .child( + h_flex() + .h_10() + .flex_none() + .px_4() + .gap_2() + .border_b_1() + .border_color(cx.theme().border) + .child(div().text_sm().font_medium().child("Commits")) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(format!("{}", commits.len())), + ) + .child(div().flex_1()) + .child( + Button::new("refresh") + .ghost() + .small() + .label("Refresh") + .tooltip_with_action("Refresh history", &Refresh, Some("Tig")) + .disabled(self.loading_history) + .on_click(cx.listener(|this, _, _, cx| this.refresh(cx))), + ), + ) + .child( + div() + .id("history-list") + .relative() + .flex_1() + .min_h_0() + .track_focus(&self.history_focus) + .key_context("TigHistory") + .role(gpui_kit::Role::List) + .aria_label("Commit history") + .on_mouse_down( + gpui_kit::MouseButton::Left, + cx.listener(|this, _, window, cx| { + this.history_focus.focus(window, cx); + }), + ) + .child( + uniform_list("commits", commits.len(), move |range, _, cx| { + range + .map(|ix| { + let commit = &commits[ix]; + let owner = owner.clone(); + let subject = if commit.subject.is_empty() { + "(No subject)".into() + } else { + commit.subject.clone() + }; + let tooltip = subject.clone(); + ListItem::new(commit.hash.clone()) + .tooltip(move |window, cx| { + Tooltip::new(tooltip.clone()).build(window, cx) + }) + .selected(selected == Some(ix)) + .secondary_selected(focused && selected == Some(ix)) + .h_12() + .px_4() + .w_full() + .accessibility_label(format!( + "{} {}", + commit.short_hash, subject + )) + .child( + v_flex() + .gap_1() + .min_w_0() + .flex_1() + .child(div().text_sm().truncate().child(subject)) + .child( + h_flex() + .gap_2() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child( + div() + .font_family( + cx.theme() + .mono_font_family + .clone(), + ) + .child(commit.short_hash.clone()), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .child(commit.author.clone()), + ) + .child(commit.date.clone()), + ), + ) + .on_click(move |_, _, cx| { + let _ = owner + .update(cx, |this, cx| this.select_commit(ix, cx)); + }) + .into_any_element() + }) + .collect() + }) + .size_full() + .track_scroll(&self.history_scroll), + ) + .child(Scrollbar::vertical(&self.history_scroll)), + ) + .into_any_element() + } + + fn render_diff(&self, cx: &mut Context) -> AnyElement { + let (additions, deletions) = self.files.iter().fold((0, 0), |(added, deleted), file| { + (added + file.additions(), deleted + file.deletions()) + }); + let content = if self.loading_history || self.loading_commit { + v_flex() + .size_full() + .items_center() + .justify_center() + .gap_3() + .child(Spinner::new()) + .child(if self.loading_history { + "Reading history…" + } else { + "Reading commit…" + }) + .into_any_element() + } else if let Some(error) = &self.error { + v_flex() + .size_full() + .p_6() + .gap_3() + .child(div().font_medium().child("Couldn't read Git data")) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(error.clone()), + ) + .child( + Button::new("retry") + .label("Retry") + .w_auto() + .on_click(cx.listener(|this, _, _, cx| this.refresh(cx))), + ) + .into_any_element() + } else if self.diff.read(cx).files().is_empty() { + v_flex() + .size_full() + .items_center() + .justify_center() + .gap_2() + .child(if self.commits.is_empty() { + "This repository has no commits" + } else { + "This commit has no file changes" + }) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(if self.commits.is_empty() { + "Create a commit, then refresh the history." + } else { + "Select another commit to inspect its changes." + }), + ) + .into_any_element() + } else { + Diff::new(&self.diff) + .line_number(self.line_numbers) + .soft_wrap(self.wrap) + .into_any_element() + }; + v_flex() + .size_full() + .min_w_0() + .min_h_0() + .when_some(self.selected_commit(), |this, commit| { + this.child( + v_flex() + .border_b_1() + .border_color(cx.theme().border) + .px_4() + .py_3() + .gap_1() + .flex_none() + .child( + h_flex() + .items_start() + .gap_1() + .child(div().min_w_0().text_base().font_medium().child( + if commit.subject.is_empty() { + "(No subject)".into() + } else { + commit.subject.clone() + }, + )) + .when(!self.message.is_empty(), |this| { + this.child( + Button::new("commit-message-toggle") + .flex_none() + .ghost() + .small() + .icon(if self.message_visible { + IconName::ChevronUp + } else { + IconName::ChevronDown + }) + .toggled(self.message_visible) + .accessibility_label(if self.message_visible { + "Hide commit message" + } else { + "Show commit message" + }) + .tooltip(if self.message_visible { + "Hide commit message" + } else { + "Show commit message" + }) + .on_click(cx.listener(|this, _, _, cx| { + this.toggle_message(cx) + })), + ) + }), + ) + .child( + h_flex() + .min_w_0() + .gap_2() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child( + div() + .flex_none() + .font_family(cx.theme().mono_font_family.clone()) + .child(commit.short_hash.clone()), + ) + .child( + Button::new("copy-hash") + .ghost() + .small() + .icon(IconName::Copy) + .accessibility_label("Copy commit hash") + .tooltip_with_action( + "Copy commit hash", + &CopyHash, + Some("Tig"), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.copy_hash(window, cx) + })), + ) + .child(div().min_w_0().truncate().child(commit.author.clone())) + .child(div().flex_none().child("·")) + .child(div().flex_none().child(commit.date.clone())) + .child(div().flex_1()) + .when(!self.loading_commit, |this| { + this.child( + h_flex() + .flex_none() + .gap_2() + .text_xs() + .child(format!("{} files", self.files.len())) + .child( + div() + .font_family( + cx.theme().mono_font_family.clone(), + ) + .child(format!("+{additions} −{deletions}")), + ), + ) + }) + .child(self.render_toolbar(cx)), + ), + ) + }) + .when( + self.message_visible && !self.loading_commit && !self.loading_history, + |this| { + this.child( + div() + .id("commit-message") + .relative() + .h_40() + .flex_none() + .min_h_0() + .border_b_1() + .border_color(cx.theme().border) + .bg(cx.theme().group_box) + .overflow_y_scroll() + .lock_scroll_axis() + .track_scroll(&self.message_scroll) + .child( + div().flex_none().px_4().py_3().text_sm().child( + TextView::markdown( + "commit-message-content", + self.message.clone(), + ) + .selectable(true), + ), + ) + .vertical_scrollbar(&self.message_scroll) + .test_support(), + ) + }, + ) + .child(div().flex_1().min_h_0().min_w_0().p_3().child(content)) + .into_any_element() + } + + fn render_files(&self, window: &Window, cx: &mut Context) -> AnyElement { + let files = self.files.clone(); + let selected_file = self.selected_file.clone(); + let owner = cx.entity().downgrade(); + let focused = self.files_focus.is_focused(window) && window.last_input_was_keyboard(); + let selected_ix = self + .selected_file + .as_ref() + .and_then(|path| files.iter().position(|file| file.path() == path)); + v_flex() + .size_full() + .min_h_0() + .child( + h_flex() + .h_10() + .flex_none() + .gap_2() + .px_4() + .border_b_1() + .border_color(cx.theme().border) + .child(div().font_medium().text_sm().child("Changed files")) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(format!("{}", files.len())), + ) + .child(div().flex_1()) + .child( + h_flex() + .gap_1() + .child( + Button::new("previous-file") + .ghost() + .small() + .icon(IconName::ChevronLeft) + .accessibility_label("Previous file") + .tooltip_with_action( + "Previous file", + &PreviousFile, + Some("Tig"), + ) + .disabled(selected_ix.is_none_or(|ix| ix == 0)) + .on_click(cx.listener(|this, _, _, cx| this.move_file(-1, cx))), + ) + .child( + Button::new("next-file") + .ghost() + .small() + .icon(IconName::ChevronRight) + .accessibility_label("Next file") + .tooltip_with_action("Next file", &NextFile, Some("Tig")) + .disabled(selected_ix.is_none_or(|ix| ix + 1 >= files.len())) + .on_click(cx.listener(|this, _, _, cx| this.move_file(1, cx))), + ), + ), + ) + .child( + div() + .id("file-list") + .relative() + .flex_1() + .min_h_0() + .track_focus(&self.files_focus) + .key_context("TigFiles") + .role(gpui_kit::Role::List) + .aria_label("Changed files") + .on_mouse_down( + gpui_kit::MouseButton::Left, + cx.listener(|this, _, window, cx| { + this.files_focus.focus(window, cx); + }), + ) + .child( + uniform_list("files", files.len(), move |range, _, cx| { + range + .map(|ix| { + let file = &files[ix]; + let path = file.path().clone(); + let owner = owner.clone(); + let (directory, filename) = + path.rsplit_once('/').unwrap_or(("", path.as_str())); + let status = match file.status() { + DiffFileStatus::Added => "A", + DiffFileStatus::Deleted => "D", + DiffFileStatus::Renamed => "R", + DiffFileStatus::Copied => "C", + DiffFileStatus::Conflicted => "U", + DiffFileStatus::Unchanged => "—", + DiffFileStatus::Modified => "M", + _ => "?", + }; + let tooltip = format!( + "{} · {:?}{}", + path, + file.status(), + if file.is_binary() { " · Binary" } else { "" } + ); + let has_counts = !file.is_binary() + && (file.additions() > 0 || file.deletions() > 0); + ListItem::new(path.clone()) + .tooltip(move |window, cx| { + Tooltip::new(tooltip.clone()).build(window, cx) + }) + .h_12() + .py_1() + .px_4() + .w_full() + .min_w_0() + .selected(selected_file.as_ref() == Some(&path)) + .secondary_selected( + focused && selected_file.as_ref() == Some(&path), + ) + .accessibility_label(format!( + "{} · {:?} · +{} −{}", + path, + file.status(), + file.additions(), + file.deletions() + )) + .child( + h_flex() + .w_full() + .min_w_0() + .gap_2() + .child( + h_flex() + .flex_1() + .min_w_0() + .gap_2() + .child( + Icon::new(IconName::FileText) + .small() + .text_color( + cx.theme().muted_foreground, + ), + ) + .child( + v_flex() + .min_w_0() + .flex_1() + .gap_0() + .child( + div() + .text_sm() + .line_height(rems(1.25)) + .truncate() + .child(filename.to_owned()), + ) + .child( + div() + .h_4() + .text_xs() + .line_height(rems(1.)) + .truncate() + .text_color( + cx.theme() + .muted_foreground, + ) + .child( + directory.to_owned(), + ), + ), + ), + ) + .child( + div() + .w_4() + .flex_none() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(status), + ) + .child( + div() + .w_12() + .flex_none() + .text_right() + .text_xs() + .font_family( + cx.theme().mono_font_family.clone(), + ) + .text_color(if file.additions() > 0 { + cx.theme().foreground + } else { + cx.theme().muted_foreground + }) + .child(if has_counts { + format!("+{}", file.additions()) + } else { + "—".into() + }), + ) + .child( + div() + .w_12() + .flex_none() + .text_right() + .text_xs() + .font_family( + cx.theme().mono_font_family.clone(), + ) + .text_color(if file.deletions() > 0 { + cx.theme().foreground + } else { + cx.theme().muted_foreground + }) + .child(if has_counts { + format!("−{}", file.deletions()) + } else { + "—".into() + }), + ), + ) + .on_click(move |_, window, cx| { + let _ = owner.update(cx, |this, cx| { + this.select_file(path.clone(), window, cx) + }); + }) + .into_any_element() + }) + .collect() + }) + .size_full() + .track_scroll(&self.files_scroll), + ) + .child(Scrollbar::vertical(&self.files_scroll)), + ) + .into_any_element() + } +} + +impl Render for Tig { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let rem = window.rem_size(); + let commit_status = if self.loading_history { + "Reading history".into() + } else if self.commits.is_empty() { + "No commits".into() + } else { + format!( + "Commit {} of {}", + self.selected.map_or(0, |ix| ix + 1), + self.commits.len() + ) + }; + let file_status = if self.loading_commit { + "Reading files".into() + } else if let Some(path) = &self.selected_file { + let ix = self + .files + .iter() + .position(|file| file.path() == path) + .unwrap_or(0); + format!("File {} of {}", ix + 1, self.files.len()) + } else { + "No file selected".into() + }; + let selection = self.diff.read(cx).selected_lines().map(|range| { + if range.side() == range.end_side() { + format!("{} lines selected", range.start().abs_diff(range.end()) + 1) + } else { + "Source selected".into() + } + }); + let navigation_focused = + self.history_focus.is_focused(window) || self.files_focus.is_focused(window); + let hint = if navigation_focused { + Kbd::binding_for_action( + &FocusDiff, + Some(if self.history_focus.is_focused(window) { + "TigHistory" + } else { + "TigFiles" + }), + window, + ) + } else { + None + }; + v_flex() + .id("tig") + .key_context("Tig") + .size_full() + .min_w_0() + .min_h_0() + .bg(cx.theme().background) + .text_color(cx.theme().foreground) + .on_action(cx.listener(|this, _: &OlderCommit, _, cx| this.move_commit(1, cx))) + .on_action(cx.listener(|this, _: &NewerCommit, _, cx| this.move_commit(-1, cx))) + .on_action(cx.listener(|this, _: &NextFile, _, cx| this.move_file(1, cx))) + .on_action(cx.listener(|this, _: &PreviousFile, _, cx| this.move_file(-1, cx))) + .on_action(cx.listener(|this, _: &FocusDiff, window, cx| this.focus_diff(window, cx))) + .on_action( + cx.listener(|this, _: &FocusHistory, window, cx| this.focus_history(window, cx)), + ) + .on_action(cx.listener(|this, _: &Refresh, _, cx| this.refresh(cx))) + .on_action(cx.listener(|this, _: &CopyHash, window, cx| this.copy_hash(window, cx))) + .on_action( + cx.listener(|this, _: &ToggleSidebar, window, cx| this.toggle_sidebar(window, cx)), + ) + .on_action(cx.listener(|this, _: &NextChange, _, cx| { + this.diff.update(cx, |state, cx| state.next_change(cx)) + })) + .on_action(cx.listener(|this, _: &PreviousChange, _, cx| { + this.diff.update(cx, |state, cx| state.previous_change(cx)) + })) + .on_action(cx.listener(|_, _: &LightAppearance, window, cx| { + Theme::change(ThemeMode::Light, Some(window), cx); + })) + .on_action(cx.listener(|_, _: &DarkAppearance, window, cx| { + Theme::change(ThemeMode::Dark, Some(window), cx); + })) + .on_action(cx.listener(|this, _: &UnifiedMode, _, cx| { + this.diff + .update(cx, |state, cx| state.set_mode(DiffMode::Unified, cx)); + })) + .on_action(cx.listener(|this, _: &SplitMode, _, cx| { + this.diff + .update(cx, |state, cx| state.set_mode(DiffMode::Split, cx)); + })) + .on_action(cx.listener(|this, _: &ToggleWrap, _, cx| { + this.wrap = !this.wrap; + cx.notify(); + })) + .on_action(cx.listener(|this, _: &ToggleNumbers, _, cx| { + this.line_numbers = !this.line_numbers; + cx.notify(); + })) + .on_action(cx.listener(|this, _: &Words, _, cx| { + this.diff.update(cx, |state, cx| { + state.set_inline_unit(Some(DiffInlineUnit::Word), cx) + }) + })) + .on_action(cx.listener(|this, _: &Characters, _, cx| { + this.diff.update(cx, |state, cx| { + state.set_inline_unit(Some(DiffInlineUnit::Character), cx) + }) + })) + .on_action(cx.listener(|this, _: &WholeLines, _, cx| { + this.diff + .update(cx, |state, cx| state.set_inline_unit(None, cx)) + })) + .on_action(cx.listener(|this, _: &ThreeContext, _, cx| { + this.diff + .update(cx, |state, cx| state.set_context_lines(Some(3), cx)) + })) + .on_action(cx.listener(|this, _: &AllContext, _, cx| { + this.diff + .update(cx, |state, cx| state.set_context_lines(None, cx)) + })) + .on_action(cx.listener(|this, _: &ExpandContext, _, cx| { + this.diff.update(cx, |state, cx| state.expand_unchanged(cx)) + })) + .on_action(cx.listener(|this, _: &CollapseContext, _, cx| { + this.diff + .update(cx, |state, cx| state.collapse_unchanged(cx)) + })) + .on_action(cx.listener(|this, _: &ToggleMessage, _, cx| { + this.toggle_message(cx); + })) + .on_action(|_: &Quit, _, cx| cx.quit()) + .child( + TitleBar::new().child( + h_flex() + .size_full() + .px_1() + .pr_3() + .gap_2() + .child(div().text_sm().font_medium().child(self.repository.name())) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(format!("{} history", self.repository.revision())), + ) + .child(div().flex_1()), + ), + ) + .child( + div().flex_1().min_h_0().child( + h_resizable("tig-panes") + .when(self.sidebar_visible, |this| { + this.child( + resizable_panel() + .size(rem * 22.) + .size_range(rem * 18. ..rem * 30.) + .child( + v_flex() + .size_full() + .min_h_0() + .bg(cx.theme().sidebar) + .child( + v_resizable("tig-navigation") + .child( + resizable_panel() + .size(rem * 25.) + .size_range(rem * 12. ..rem * 100.) + .child(self.render_history(window, cx)), + ) + .child( + resizable_panel() + .size_range(rem * 10. ..rem * 100.) + .child(self.render_files(window, cx)), + ), + ), + ), + ) + }) + .child( + resizable_panel() + .size_range(rem * 38. ..rem * 200.) + .child(self.render_diff(cx)), + ), + ), + ) + .child( + StatusBar::new() + .left( + h_flex().child( + Button::new("toggle-sidebar") + .toggled(self.sidebar_visible) + .ghost() + .small() + .icon(if self.sidebar_visible { + IconName::PanelLeftClose + } else { + IconName::PanelLeftOpen + }) + .accessibility_label(if self.sidebar_visible { + "Hide sidebar" + } else { + "Show sidebar" + }) + .tooltip_with_action( + if self.sidebar_visible { + "Hide sidebar" + } else { + "Show sidebar" + }, + &ToggleSidebar, + Some("Tig"), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.toggle_sidebar(window, cx) + })), + ), + ) + .left( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(commit_status), + ) + .right( + h_flex() + .gap_3() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(file_status) + .when_some(selection, |this, text| this.child(text)) + .when_some(hint, |this, key| { + this.child(h_flex().gap_1().child(key).child("Read diff")) + }), + ), + ) + } +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/examples/tig/src/main.rs b/examples/tig/src/main.rs new file mode 100644 index 0000000000..e43400361a --- /dev/null +++ b/examples/tig/src/main.rs @@ -0,0 +1,104 @@ +mod app; +mod repository; + +use std::path::PathBuf; + +use gpui_kit::{ + AppContext as _, SharedString, WindowBounds, WindowOptions, component::TitleBar, px, size, +}; + +use app::Tig; +use repository::Repository; + +fn main() { + let mut path = None; + let mut check = false; + let mut commit = None; + let mut file = None; + let mut line = None; + let mut arguments = std::env::args_os().skip(1); + const USAGE: &str = + "Usage: example-tig [--check] [--commit HASH] [--file PATH] [--line N] [repository]"; + while let Some(argument) = arguments.next() { + if argument == "--help" { + println!("{USAGE}\nDefaults to the current directory and HEAD. Requires Git on PATH."); + return; + } else if argument == "--check" { + check = true; + } else if argument == "--commit" || argument == "--file" || argument == "--line" { + let Some(value) = arguments.next().and_then(|value| value.into_string().ok()) else { + eprintln!("{} requires a value.\n{USAGE}", argument.to_string_lossy()); + std::process::exit(2); + }; + if argument == "--commit" { + commit = Some(SharedString::from(value)); + } else if argument == "--file" { + file = Some(SharedString::from(value)); + } else { + line = value.parse::().ok().filter(|line| *line > 0); + if line.is_none() { + eprintln!("Line must be a positive integer."); + std::process::exit(2); + } + } + } else if !argument.to_string_lossy().starts_with('-') && path.is_none() { + path = Some(PathBuf::from(argument)); + } else { + eprintln!("{USAGE}"); + std::process::exit(2); + } + } + if line.is_some() && file.is_none() { + eprintln!("--line requires --file."); + std::process::exit(2); + } + let mut repository = Repository::new(path.unwrap_or_else(|| PathBuf::from("."))); + if let Some(commit) = commit { + repository = repository.with_revision(commit).unwrap_or_else(|error| { + eprintln!("{error}"); + std::process::exit(2); + }); + } + if check { + let result = repository.history().and_then(|commits| { + println!( + "{}: {} commits in the history window", + repository.label(), + commits.len() + ); + if let Some(commit) = commits.first() { + let details = repository.details(&commit.hash)?; + println!( + "{} {}: {} changed files", + commit.short_hash, + commit.subject, + details.files.len() + ); + } + Ok(()) + }); + if let Err(error) = result { + eprintln!("{error}"); + std::process::exit(1); + } + return; + } + + gpui_kit::application() + .with_assets(gpui_kit::assets::Assets) + .run(move |cx| { + gpui_kit::init(cx); + app::init(cx); + let options = WindowOptions { + window_bounds: Some(WindowBounds::centered(size(px(1400.), px(860.)), cx)), + window_min_size: Some(size(px(1100.), px(640.))), + app_id: Some("gpui-kit-tig".into()), + ..TitleBar::window_options() + }; + gpui_kit::open_window(options, cx, |window, cx| { + cx.new(|cx| Tig::new(repository, window, cx).with_initial_file(file, line)) + }) + .expect("Failed to open Git history window"); + cx.activate(true); + }); +} diff --git a/examples/tig/src/repository.rs b/examples/tig/src/repository.rs new file mode 100644 index 0000000000..c77e8ed453 --- /dev/null +++ b/examples/tig/src/repository.rs @@ -0,0 +1,225 @@ +use std::{path::PathBuf, process::Command}; + +use gpui_kit::{SharedString, component::diff::DiffFile}; + +const HISTORY_LIMIT: usize = 200; + +#[derive(Clone, Debug)] +pub(super) struct Commit { + pub(super) hash: SharedString, + pub(super) short_hash: SharedString, + pub(super) author: SharedString, + pub(super) date: SharedString, + pub(super) subject: SharedString, +} + +pub(super) struct CommitDetails { + pub(super) message: SharedString, + pub(super) files: Vec, +} + +#[derive(Clone)] +pub(super) struct Repository { + path: PathBuf, + revision: SharedString, +} + +impl Repository { + pub(super) fn new(path: PathBuf) -> Self { + Self { + path: std::fs::canonicalize(&path).unwrap_or(path), + revision: "HEAD".into(), + } + } + + pub(super) fn with_revision(mut self, revision: SharedString) -> Result { + if !(7..=64).contains(&revision.len()) + || !revision.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err("Commit must be a hexadecimal object ID of 7–64 characters.".into()); + } + self.revision = revision; + Ok(self) + } + + pub(super) fn revision(&self) -> &SharedString { + &self.revision + } + + pub(super) fn label(&self) -> String { + self.path.display().to_string() + } + + pub(super) fn name(&self) -> String { + self.path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "Repository".into()) + } + + fn command(&self) -> Command { + let mut command = Command::new("git"); + command.arg("--no-pager").arg("-C").arg(&self.path); + command + } + + fn read(&self, args: &[&str]) -> Result { + let output = self + .command() + .args(args) + .output() + .map_err(|error| format!("Couldn't run Git: {error}"))?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).trim().to_owned()); + } + String::from_utf8(output.stdout).map_err(|_| { + "This Git output isn't UTF-8. Convert the source encoding to view it.".into() + }) + } + + pub(super) fn history(&self) -> Result, String> { + // Verify the repository separately so an unborn HEAD is an empty state, + // while a missing directory or a non-repository remains a useful error. + self.read(&["rev-parse", "--git-dir"])?; + let head = self + .command() + .args(["rev-parse", "--verify", "--quiet", self.revision.as_str()]) + .output() + .map_err(|error| format!("Couldn't read HEAD: {error}"))?; + if !head.status.success() { + return if self.revision == "HEAD" { + Ok(Vec::new()) + } else { + Err(format!("Commit {} was not found.", self.revision)) + }; + } + let output = self.read(&[ + "log", + "--no-show-signature", + "--encoding=UTF-8", + "--date=short", + "-z", + &format!("--max-count={HISTORY_LIMIT}"), + "--format=%H%x00%h%x00%an%x00%ad%x00%s", + self.revision.as_str(), + "--", + ])?; + parse_history(&output) + } + + pub(super) fn details(&self, hash: &str) -> Result { + if !is_object_id(hash) { + return Err("Invalid commit object ID.".into()); + } + let message = self.read(&[ + "show", + "--no-show-signature", + "--encoding=UTF-8", + "--no-patch", + "--format=%B", + hash, + "--", + ])?; + // A merge is compared with its first parent instead of emitting the + // combined format, which a two-sided Diff cannot represent. + let patch = self.read(&[ + "show", + "--no-show-signature", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--format=", + "--first-parent", + "--patch", + "--find-renames", + "--src-prefix=a/", + "--dst-prefix=b/", + hash, + "--", + ])?; + let files = DiffFile::parse(&patch).map_err(|error| error.to_string())?; + Ok(CommitDetails { + message: message + .split_once("\n\n") + .map(|(_, body)| body.trim_end()) + .unwrap_or("") + .to_owned() + .into(), + files, + }) + } +} + +fn is_object_id(value: &str) -> bool { + matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn parse_history(output: &str) -> Result, String> { + if output.is_empty() { + return Ok(Vec::new()); + } + let fields = output + .strip_suffix('\0') + .unwrap_or(output) + .split('\0') + .collect::>(); + if fields.len() % 5 != 0 { + return Err("Git returned an incomplete commit record.".into()); + } + fields + .chunks_exact(5) + .map(|fields| { + if !is_object_id(fields[0]) { + return Err("Git returned an invalid commit object ID.".into()); + } + Ok(Commit { + hash: fields[0].to_owned().into(), + short_hash: fields[1].to_owned().into(), + author: fields[2].to_owned().into(), + date: fields[3].to_owned().into(), + subject: fields[4].to_owned().into(), + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn history_records_keep_unicode_tabs_and_empty_subjects() { + let first = "a".repeat(40); + let second = "b".repeat(64); + let output = format!( + "{first}\0aaaaaaa\0李明\02026-10-07\0Fix tabs\tand Unicode 🦀\0{second}\0bbbbbbb\0Another author\02026-10-06\0\0" + ); + let commits = parse_history(&output).unwrap(); + assert_eq!(commits.len(), 2); + assert_eq!(commits[0].author.as_str(), "李明"); + assert_eq!(commits[0].subject.as_str(), "Fix tabs\tand Unicode 🦀"); + assert!(commits[1].subject.is_empty()); + assert_eq!(commits[1].hash.len(), 64); + assert!(parse_history("").unwrap().is_empty()); + } + + #[test] + fn incomplete_records_and_option_like_hashes_are_rejected() { + assert!(parse_history("hash\0short\0author").is_err()); + assert!(parse_history("--output=somewhere\0short\0author\0date\0subject\0").is_err()); + assert!(!is_object_id(&"g".repeat(40))); + assert!(!is_object_id(&"a".repeat(39))); + for invalid in ["--output=somewhere", "HEAD", "abc", "zzzzzzz"] { + assert!( + Repository::new(PathBuf::from(".")) + .with_revision(invalid.into()) + .is_err() + ); + } + assert!( + Repository::new(PathBuf::from(".")) + .with_revision("4890b1c2".into()) + .is_ok() + ); + } +} diff --git a/examples/tig/src/tests.rs b/examples/tig/src/tests.rs new file mode 100644 index 0000000000..e617387e65 --- /dev/null +++ b/examples/tig/src/tests.rs @@ -0,0 +1,238 @@ +use std::{path::PathBuf, sync::Arc}; + +use gpui_kit::{ + AppContext as _, Focusable as _, Task, TestAppContext, WindowOptions, + component::diff::DiffFile, test::TestWindowExt as _, +}; + +use super::{Commit, CommitDetails, Repository, Tig}; + +fn details(path: &str, content: &str) -> CommitDetails { + CommitDetails { + message: "Commit body".into(), + files: DiffFile::parse(&format!( + "--- /dev/null\n+++ b/{path}\n@@ -0,0 +1 @@\n+{content}\n" + )) + .unwrap(), + } +} + +#[gpui_kit::test] +fn commit_navigation_discards_old_results_and_preserves_keyboard_focus(cx: &mut TestAppContext) { + cx.update(gpui_kit::init); + cx.update(super::init); + let (handle, view) = cx.update(|cx| { + gpui_kit::open_window(WindowOptions::default(), cx, |window, cx| { + cx.new(|cx| { + let mut view = Tig::new(Repository::new(PathBuf::from(".")), window, cx); + view.seed_history(cx); + view + }) + }) + .unwrap() + }); + cx.update_window(handle, |_, window, cx| { + window.render_frame(cx); + window.press("down", cx); + view.update(cx, |view, cx| { + view.verify_loading_and_install(cx); + }); + window.render_frame(cx); + window.press("]", cx); + assert_eq!(view.read(cx).selected_file.as_deref(), Some("other.txt")); + assert_eq!(view.read(cx).diff.read(cx).files().len(), 1); + assert_eq!( + view.read(cx).diff.read(cx).files()[0].path().as_str(), + "other.txt" + ); + window.press("enter", cx); + assert!( + view.read(cx) + .diff + .read(cx) + .focus_handle(cx) + .is_focused(window) + ); + window.press("escape", cx); + assert!(view.read(cx).history_focus.is_focused(window)); + window.render_frame(cx); + window.click(gpui_kit::SharedString::new("new.txt"), cx); + assert!(view.read(cx).files_focus.is_focused(window)); + window.press("down", cx); + assert_eq!(view.read(cx).selected_file.as_deref(), Some("other.txt")); + window.press("enter", cx); + assert!( + view.read(cx) + .diff + .read(cx) + .focus_handle(cx) + .is_focused(window) + ); + window.press( + if cfg!(target_os = "macos") { + "cmd-b" + } else { + "ctrl-b" + }, + cx, + ); + assert!(!view.read(cx).sidebar_visible); + window.render_frame(cx); + window.press("escape", cx); + window.render_frame(cx); + assert!(view.read(cx).sidebar_visible); + assert!(view.read(cx).history_focus.is_focused(window)); + }) + .unwrap(); +} + +// Test-only access stays inside the example, rather than expanding its public API. +impl Tig { + fn seed_history(&mut self, cx: &mut gpui_kit::Context) { + self._history_task = Task::ready(()); + self.loading_history = false; + self.commits = Arc::new( + (0..2) + .map(|ix| Commit { + hash: if ix == 0 { + "a".repeat(40) + } else { + "b".repeat(40) + } + .into(), + short_hash: if ix == 0 { "aaaaaaa" } else { "bbbbbbb" }.into(), + author: "Example author".into(), + date: "2026-10-07".into(), + subject: format!("Commit {ix}").into(), + }) + .collect(), + ); + self.selected = Some(0); + self.revision = 100; + self.install_commit(100, Ok(details("old.txt", "old")), cx); + } + + fn verify_loading_and_install(&mut self, cx: &mut gpui_kit::Context) { + assert_eq!(self.selected, Some(1)); + assert!(self.loading_commit); + assert!(self.diff.read(cx).files().is_empty()); + self._commit_task = Task::ready(()); + self.install_commit(self.revision - 1, Ok(details("old.txt", "old")), cx); + assert!(self.loading_commit); + assert!(self.selected_file.is_none()); + let mut commit = details("new.txt", "new"); + commit.files.extend(details("other.txt", "other").files); + self.install_commit(self.revision, Ok(commit), cx); + assert!(!self.loading_commit); + assert_eq!(self.selected_file.as_deref(), Some("new.txt")); + assert_eq!(self.diff.read(cx).files()[0].path().as_str(), "new.txt"); + } +} + +#[gpui_kit::test] +fn long_commit_message_scrolls_and_resets_when_the_commit_changes(cx: &mut TestAppContext) { + use gpui_kit::component::{ + Theme, + diff::{DiffLineRange, DiffSide}, + }; + use gpui_kit::{ + Bounds, InputEvent as _, ScrollDelta, ScrollWheelEvent, TouchPhase, WindowBounds, point, + px, size, + }; + + cx.update(gpui_kit::init); + cx.update(super::init); + let (handle, view) = cx.update(|cx| { + gpui_kit::open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds::new( + point(px(0.), px(0.)), + size(px(1200.), px(800.)), + ))), + ..WindowOptions::default() + }, + cx, + |window, cx| { + cx.new(|cx| { + let mut view = Tig::new(Repository::new(PathBuf::from(".")), window, cx); + view.seed_history(cx); + view.message = (0..60) + .map(|ix| { + format!("Paragraph {ix}: explain this change and its validation.\n\n") + }) + .collect::() + .into(); + view + }) + }, + ) + .unwrap() + }); + cx.update_window(handle, |_, window, cx| { + window.render_frame(cx); + window.click("commit-message-toggle", cx); + view.read(cx).diff.clone().update(cx, |state, cx| { + state.set_selected_lines( + Some(DiffLineRange::new("old.txt", DiffSide::Modified, 1, 1)), + cx, + ); + }); + window.render_frame(cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle, |_, window, cx| { + window.render_frame(cx); + assert!(Theme::global(cx).is_dark()); + let message = window.find("commit-message").bounds(); + window.dispatch_event( + ScrollWheelEvent { + position: message.center(), + delta: ScrollDelta::Pixels(point(px(0.), px(-120.))), + touch_phase: TouchPhase::Moved, + modifiers: Default::default(), + } + .to_platform_input(), + cx, + ); + assert!(view.read(cx).message_scroll.offset().y < px(0.)); + assert_eq!(view.read(cx).diff.read(cx).selected_text(cx), "old\n"); + window.press("escape", cx); + window.press("down", cx); + assert!(!view.read(cx).message_visible); + assert_eq!(view.read(cx).message_scroll.offset().y, px(0.)); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn startup_file_is_selected_once_and_missing_paths_report_an_error(cx: &mut TestAppContext) { + cx.update(gpui_kit::init); + let (handle, view) = cx.update(|cx| { + gpui_kit::open_window(WindowOptions::default(), cx, |window, cx| { + cx.new(|cx| { + let mut view = Tig::new(Repository::new(PathBuf::from(".")), window, cx); + view._history_task = Task::ready(()); + view.loading_history = false; + view.with_initial_file(Some("other.txt".into()), Some(1)) + }) + }) + .unwrap() + }); + cx.update_window(handle, |_, window, cx| { + view.update(cx, |view, cx| { + let mut commit = details("new.txt", "new"); + commit.files.extend(details("other.txt", "other").files); + view.install_commit(view.revision, Ok(commit), cx); + assert_eq!(view.selected_file.as_deref(), Some("other.txt")); + assert!(view.initial_line.is_none()); + view.install_commit(view.revision, Ok(details("new.txt", "new")), cx); + assert_eq!(view.selected_file.as_deref(), Some("new.txt")); + view.initial_file = Some("missing.txt".into()); + view.install_commit(view.revision, Ok(details("new.txt", "new")), cx); + assert!(view.error.as_deref().unwrap().contains("missing.txt")); + }); + window.render_frame(cx); + }) + .unwrap(); +} diff --git a/website/component/diff.md b/website/component/diff.md index 678d3f6751..e7c54b3956 100644 --- a/website/component/diff.md +++ b/website/component/diff.md @@ -20,7 +20,7 @@ whole source file for reference, or a working file with merge conflicts. ```rust use gpui_kit::*; use gpui_kit::component::diff::{ - Diff, DiffAnnotation, DiffChangeIndicator, DiffFile, DiffHoverHighlight, + Diff, DiffAnnotation, DiffChangeIndicator, DiffConflictResolution, DiffFile, DiffHoverHighlight, DiffHunkSeparator, DiffInlineUnit, DiffLinePosition, DiffLineRange, DiffMode, DiffSide, DiffState, }; @@ -55,6 +55,19 @@ Do not parse the patch or recreate its state on every render. Parsing is fast patches, parse on a background executor, then install the files only if the result still belongs to the current patch revision. +## Git history example + +For a complete application, see the [tig example](https://github.com/longbridge/gpui-kit/tree/main/examples/tig). +It reads local Git history and patches on a background executor, uses virtualized +commit/file navigation in a resizable sidebar, and displays the selected file +with one retained `DiffState`. Stale requests cannot overwrite a newer commit. +The example includes keyboard navigation, source copying, runtime display +options, loading/error/empty states and a collapsible commit message. + +```sh +cargo run -p example-tig -- /path/to/repository +``` + ## Files Accept unified diffs (`---`, `+++`, `@@`) and Git diff text, including @@ -107,6 +120,32 @@ if let Some(text) = state.resolved_text("src/retry.rs") { `resolve_conflict(path, ix, resolution, cx)` sets or clears it without emitting an event. A user's choice emits `DiffEvent::ConflictResolved(path, ix)`. +`DiffFile::conflicts()` returns readonly `DiffConflict` descriptors in working-file +order. Their slice indices are the `ix` used by resolution methods and events; +`conflicts().len()` gives the count. `lines()` includes the marker lines; +`current_lines()`, `base_lines()` and `incoming_lines()` locate each source part. +These ranges use one-based working-file coordinates with an **exclusive end**, +so an empty part is represented by `start == end`. `base_lines()` and +`base_label()` return `None` for a two-way conflict. The labels are available +through `current_label()`, `base_label()` and `incoming_label()`. + +For example, an application command can accept every incoming change without +parsing the working file again: + +```rust +self.diff.update(cx, |state, cx| { + let path = "src/retry.rs"; + let count = state.files().iter().find(|file| file.path().as_str() == path) + .map_or(0, |file| file.conflicts().len()); + for ix in 0..count { + state.resolve_conflict(path, ix, Some(DiffConflictResolution::Incoming), cx); + } +}); +``` + +`conflict_resolution` returns `None` for an unresolved conflict or an unknown +file/index; enumerate `conflicts()` to distinguish valid unresolved conflicts. + ## Display mode and context The default display mode is `DiffMode::Unified`, with up to three available @@ -266,23 +305,35 @@ whitespace is retained for copying; a patch `\ No newline at end of file` marker is shown explicitly. Rows are virtualized across all files, including annotation height. Diff owns its scrolling; avoid nesting it in another scrolling region. +The `with_*` state builders configure initial values. To change them on the +retained entity, use `set_expansion_lines`, `set_min_collapsed_lines`, +`set_inline_unit`, `set_inline_max_line_length` and `set_syntax_max_line_length`. +These methods notify observers and preserve selection and the nearby source +position; emphasis changes discard stale preparation and prepare it again. + +```rust +self.diff.update(cx, |state, cx| { + state.set_inline_unit(Some(DiffInlineUnit::Character), cx); +}); +``` + ## File headers The default header shows the collapse control, the path, a rename as `old → new`, the addition and deletion counts, and the file status. Three slots -add application content to it, and `header` replaces its content while keeping +add application content to it, and `render_header` replaces its content while keeping the shell, separator and collapse control: | Builder | Placement | | --- | --- | -| `header_prefix` | Before the path | -| `header_title_suffix` | Immediately after the path | -| `header_suffix` | After the counts and status | -| `header` | Replaces the content | +| `render_header_prefix` | Before the path | +| `render_header_title_suffix` | Immediately after the path | +| `render_header_suffix` | After the counts and status | +| `render_header` | Replaces the content | ```rust Diff::new(&self.diff) - .header_suffix(|file, _, _| div().child(format!("{} lines", file.additions()))) + .render_header_suffix(|file, _, _| div().child(format!("{} lines", file.additions()))) .h(rems(24.)) ``` @@ -293,6 +344,10 @@ owning view's `Context`. Construct the content in the callback and update state from its event handlers. Do not synchronously read or update the same `DiffState` entity inside a render callback. +The earlier names `header`, `header_prefix`, `header_title_suffix`, +`header_suffix` and `annotation_content` remain available as compatibility +aliases for the corresponding `render_*` builders. + ## Annotations An annotation has a stable application ID and a target: a source line, or a @@ -310,11 +365,11 @@ let annotations = [ Diff::new(&self.diff) .annotations(annotations) - .annotation_content(|annotation, _, _| div().child(annotation.id().to_string())) + .render_annotation(|annotation, _, _| div().child(annotation.id().to_string())) .h(rems(24.)) ``` -Supply both `annotations` and `annotation_content`. The callback receives +Supply both `annotations` and `render_annotation`. The callback receives `&DiffAnnotation`; use `id()`, `path()` and `position()`, which is `None` for a file annotation, to find application content. Annotation IDs must be stable and unique within the Diff. An annotation on hidden context appears when that source @@ -340,6 +395,11 @@ Diff::new(&self.diff).on_add_annotation(cx.listener(|this, range: &DiffLineRange ## Pointer and keyboard interaction +Wheel axes remain independent: vertical input scrolls the source rows; +horizontal input (including a platform's Shift+wheel gesture) scrolls long +lines without moving the vertical position. Soft wrapping removes horizontal +overflow. + Drag over code to select supplied text on one side of one file, including across virtualized or folded available ranges. Unavailable gaps are not selectable. Click a line number to select that source line, drag across line numbers to diff --git a/website/zh-CN/component/diff.md b/website/zh-CN/component/diff.md index 88f410659a..e9d2898789 100644 --- a/website/zh-CN/component/diff.md +++ b/website/zh-CN/component/diff.md @@ -17,7 +17,7 @@ description: 用于代码审阅、合并冲突和源文件预览的只读 unifie ```rust use gpui_kit::*; use gpui_kit::component::diff::{ - Diff, DiffAnnotation, DiffChangeIndicator, DiffFile, DiffHoverHighlight, + Diff, DiffAnnotation, DiffChangeIndicator, DiffConflictResolution, DiffFile, DiffHoverHighlight, DiffHunkSeparator, DiffInlineUnit, DiffLinePosition, DiffLineRange, DiffMode, DiffSide, DiffState, }; @@ -49,6 +49,17 @@ Diff::new(&self.diff).h(rems(24.)).w_full() 200 个文件的 patch 约需 15 ms);patch 特别大时,在后台 executor 中解析, 并在确认结果仍对应当前 patch 版本后再安装文件。 +## Git history 示例 + +完整应用见 [tig example](https://github.com/longbridge/gpui-kit/tree/main/examples/tig)。 +它在后台执行器中读取本地 Git 历史和 patch,在可调整大小的侧栏中提供虚拟化的提交、文件导航, +并用一个保留的 `DiffState` 显示当前文件。过期请求不会覆盖新选择的提交。 +示例还包含键盘导航、源文本复制、运行时显示选项、加载/错误/空状态,以及可折叠的提交正文。 + +```sh +cargo run -p example-tig -- /path/to/repository +``` + ## 文件 支持 unified diff(`---`、`+++`、`@@`)和 Git diff 文本,包括 `git format-patch` 的输出, @@ -92,6 +103,29 @@ if let Some(text) = state.resolved_text("src/retry.rs") { 读取单个冲突的解决结果,`resolve_conflict(path, ix, resolution, cx)` 设置或清除结果且不发出事件。 用户的选择会发出 `DiffEvent::ConflictResolved(path, ix)`。 +`DiffFile::conflicts()` 按工作文件中的顺序返回只读的 `DiffConflict` 描述。 +切片索引就是解决方法和事件中的 `ix`;`conflicts().len()` 返回冲突数量。 +`lines()` 包含标记行,`current_lines()`、`base_lines()` 和 `incoming_lines()` +给出各部分源文本的位置。范围使用从 1 开始的工作文件行号,**结束行不包含在范围内**, +因此空部分用 `start == end` 表示。双向冲突的 `base_lines()` 和 `base_label()` +返回 `None`。通过 `current_label()`、`base_label()` 和 `incoming_label()` 读取标签。 + +例如,应用命令可以采用所有传入的更改,无需再次解析工作文件: + +```rust +self.diff.update(cx, |state, cx| { + let path = "src/retry.rs"; + let count = state.files().iter().find(|file| file.path().as_str() == path) + .map_or(0, |file| file.conflicts().len()); + for ix in 0..count { + state.resolve_conflict(path, ix, Some(DiffConflictResolution::Incoming), cx); + } +}); +``` + +未解决的冲突和不存在的文件或索引都会让 `conflict_resolution` 返回 `None`; +通过 `conflicts()` 枚举有效的冲突,可以区分这两种情况。 + ## 显示模式与上下文 默认采用 `DiffMode::Unified` 显示模式,每处变更前后最多保留三行 patch 已提供的未修改上下文。 @@ -225,21 +259,32 @@ Diff::new(&self.diff) `\ No newline at end of file` 标记会明确显示。所有文件的行共同参与虚拟化, 批注高度也参与测量。Diff 管理自身的滚动,应避免再将它嵌入另一个滚动区域。 +状态的 `with_*` builder 设置初始值。若要更新已有 entity,使用 +`set_expansion_lines`、`set_min_collapsed_lines`、`set_inline_unit`、 +`set_inline_max_line_length` 和 `set_syntax_max_line_length`。 +这些方法会通知观察者,并保留选区与附近的源文本位置;强调方式改变时会丢弃旧的准备结果并重新准备。 + +```rust +self.diff.update(cx, |state, cx| { + state.set_inline_unit(Some(DiffInlineUnit::Character), cx); +}); +``` + ## 文件头 默认文件头显示收起控件、路径、以 `old → new` 表示的重命名、新增与删除行数以及文件状态。 -三个插槽可向其中添加应用内容;`header` 替换文件头内容,保留外层布局、分隔线和收起控件: +三个插槽可向其中添加应用内容;`render_header` 替换文件头内容,保留外层布局、分隔线和收起控件: | Builder | 位置 | | --- | --- | -| `header_prefix` | 路径前方 | -| `header_title_suffix` | 紧接路径之后 | -| `header_suffix` | 行数统计和状态之后 | -| `header` | 替换文件头内容 | +| `render_header_prefix` | 路径前方 | +| `render_header_title_suffix` | 紧接路径之后 | +| `render_header_suffix` | 行数统计和状态之后 | +| `render_header` | 替换文件头内容 | ```rust Diff::new(&self.diff) - .header_suffix(|file, _, _| div().child(format!("{} lines", file.additions()))) + .render_header_suffix(|file, _, _| div().child(format!("{} lines", file.additions()))) .h(rems(24.)) ``` @@ -248,6 +293,9 @@ Diff::new(&self.diff) 接收的是 `App`,不是所属视图的 `Context`。在回调中构建内容,在内容的事件处理函数中修改状态。 不要在渲染回调内同步读取或更新同一个 `DiffState` 实体。 +原有的 `header`、`header_prefix`、`header_title_suffix`、`header_suffix` 和 +`annotation_content` 仍然可用,作为对应 `render_*` builder 的兼容别名。 + ## 批注 批注具有稳定的应用 ID 和目标:某个源文件行,或整个文件。评论内容与草稿状态由应用持有, @@ -264,11 +312,11 @@ let annotations = [ Diff::new(&self.diff) .annotations(annotations) - .annotation_content(|annotation, _, _| div().child(annotation.id().to_string())) + .render_annotation(|annotation, _, _| div().child(annotation.id().to_string())) .h(rems(24.)) ``` -同时提供 `annotations` 和 `annotation_content`。回调接收 `&DiffAnnotation`,可通过 `id()`、 +同时提供 `annotations` 和 `render_annotation`。回调接收 `&DiffAnnotation`,可通过 `id()`、 `path()` 和 `position()` 查找应用内容;文件级批注的 `position()` 为 `None`。批注 ID 在同一个 Diff 中必须稳定且唯一。批注位于隐藏上下文时,对应源文件行展开后才会显示。 无效行位置或不存在的一侧不会渲染批注。Unified 中未修改的行只显示一次,但可以显示两侧的批注。 @@ -288,6 +336,9 @@ Diff::new(&self.diff).on_add_annotation(cx.listener(|this, range: &DiffLineRange ## 鼠标与键盘交互 +滚轮的两个方向独立处理:垂直输入滚动源文本行;水平输入(包括平台转换后的 +Shift+滚轮手势)滚动长行,不改变垂直位置。启用软换行后不再产生水平溢出。 + 在代码上拖动可选择同一文件同一侧的源文本,选区可以跨越虚拟列表范围和已有上下文的折叠范围, 不能选择未提供的源文本。点击行号选择该行,在行号上拖动选择一个范围,按住 Shift 点击另一个行号 扩展为包含两端的范围。Unified 模式中,范围可以从删除行跨到新增行;Split 模式中切换到另一侧会开始新的选区。