//! What a turn changed on disk: the file tree under a finished turn, and the //! diff view it opens. use std::collections::{BTreeMap, HashSet}; use std::rc::Rc; use gpui_kit::component::{Icon, IconName, Selectable as _, h_flex, v_flex}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use crate::git::{DiffLine, FileChange, FileDiff, LineKind, TurnChanges}; use crate::session::Session; use crate::ui::icons::Lucide; use crate::ui::metal::{self, MetalButton, MetalStyled as _, Palette}; /// Diffs longer than this are cut short; the rest is a click away in an editor. const MAX_DIFF_LINES: usize = 4_000; #[derive(Default)] struct Dir { children: BTreeMap, files: Vec, } impl Dir { fn totals(&self) -> (u64, u64) { let own = self.files.iter().fold((0, 0), |(added, removed), file| { (added + file.added.unwrap_or(0), removed + file.removed.unwrap_or(0)) }); self.children.values().map(Dir::totals).fold(own, |(a, r), (ca, cr)| (a + ca, r + cr)) } } enum Row { Dir { path: String, name: String, depth: usize, open: bool, added: u64, removed: u64 }, File { change: FileChange, name: String, depth: usize }, } fn build_tree(files: &[FileChange]) -> Dir { let mut root = Dir::default(); for file in files { let mut parts: Vec<&str> = file.path.split('/').collect(); parts.pop(); let mut dir = &mut root; for part in parts { dir = dir.children.entry(part.to_string()).or_default(); } dir.files.push(file.clone()); } root } /// Every folder path in the tree, as the rows name them. pub fn all_dirs(changes: &TurnChanges) -> HashSet { let mut rows = Vec::new(); flatten(&build_tree(&changes.files), "", 0, &|_| true, &mut rows); rows.into_iter() .filter_map(|row| match row { Row::Dir { path, .. } => Some(path), Row::File { .. } => None, }) .collect() } /// Lays the tree out as rows, folders first. A folder holding nothing but /// one other folder shares its row (`src/ui`). fn flatten(dir: &Dir, prefix: &str, depth: usize, open: &dyn Fn(&str) -> bool, rows: &mut Vec) { for (name, child) in &dir.children { let mut name = name.clone(); let mut child = child; while child.files.is_empty() && child.children.len() == 1 { let (grandchild_name, grandchild) = child.children.iter().next().unwrap(); name = format!("{name}/{grandchild_name}"); child = grandchild; } let path = if prefix.is_empty() { name.clone() } else { format!("{prefix}/{name}") }; let is_open = open(&path); let (added, removed) = child.totals(); rows.push(Row::Dir { path: path.clone(), name, depth, open: is_open, added, removed }); if is_open { flatten(child, &path, depth + 1, open, rows); } } for file in &dir.files { let name = file.path.rsplit('/').next().unwrap_or(&file.path).to_string(); rows.push(Row::File { change: file.clone(), name, depth }); } } pub fn format_count(count: u64) -> String { if count < 1_000 { count.to_string() } else { let value = format!("{:.1}", count as f64 / 1_000.); format!("{}k", value.trim_end_matches(".0")) } } fn counts(added: Option, removed: Option, p: &Palette) -> Div { h_flex() .gap(px(8.)) .font_family(metal::MONO) .text_size(px(11.5)) .map(|this| match (added, removed) { (Some(added), Some(removed)) => this .child(div().text_color(p.green_ink).child(format!("+{}", format_count(added)))) .child(div().text_color(p.red).child(format!("−{}", format_count(removed)))), _ => this.child(div().text_color(p.ink3).child("binary")), }) } fn file_icon(name: &str) -> Icon { let extension = name.rsplit_once('.').map(|(_, extension)| extension.to_ascii_lowercase()); match extension.as_deref() { Some("md" | "txt" | "rst") => Icon::new(Lucide::FileText), Some( "rs" | "ts" | "tsx" | "js" | "jsx" | "py" | "go" | "c" | "h" | "cpp" | "java" | "kt" | "swift" | "rb" | "toml" | "json" | "yaml" | "yml" | "css" | "html" | "sh", ) => Icon::new(Lucide::FileCode), _ => Icon::new(IconName::File), } } /// The overview under a finished turn: `N changed files +a −r`, a folder tree /// with per-file counts, and a way into the diff. pub fn render_changes( session: &Entity, end: usize, changes: &TurnChanges, on_open: impl Fn(Option, &mut Window, &mut App) + Clone + 'static, cx: &App, ) -> AnyElement { let p = metal::palette(cx); let state = session.read(cx); let mut rows = Vec::new(); flatten(&build_tree(&changes.files), "", 0, &|path| state.dir_expanded(end, path), &mut rows); let any_open = rows.iter().any(|row| matches!(row, Row::Dir { open: true, .. })); let count = changes.files.len(); let toggle_all = { let session = session.clone(); let dirs = if any_open { HashSet::new() } else { all_dirs(changes) }; move |_: &ClickEvent, _: &mut Window, cx: &mut App| { session.update(cx, |session, cx| session.set_dirs(end, dirs.clone(), cx)); } }; let open_all = { let on_open = on_open.clone(); move |_: &ClickEvent, window: &mut Window, cx: &mut App| on_open(None, window, cx) }; v_flex() .w_full() .mt(px(16.)) .rounded(px(10.)) .region(p) .child( h_flex() .pl(px(12.)) .pr(px(8.)) .py(px(8.)) .gap(px(12.)) .child( div() .text_size(px(12.)) .font_weight(FontWeight::MEDIUM) .text_color(p.ink) .child(format!("{count} changed file{}", if count == 1 { "" } else { "s" })), ) .child(counts(Some(changes.added()), Some(changes.removed()), p)) .child(div().flex_1()) .when(rows.iter().any(|row| matches!(row, Row::Dir { .. })), |this| { this.child( MetalButton::new(("changes-toggle-all", end)) .ghost() .mini() .icon(Lucide::ChevronsUpDown) .tooltip(if any_open { "Collapse all folders" } else { "Expand all folders" }) .on_click(toggle_all), ) }) .child( MetalButton::new(("changes-open-diff", end)) .ghost() .compact() .icon(Lucide::FileDiff) .label("Open diff") .tooltip("Open the full diff") .on_click(open_all), ), ) .child(v_flex().p(px(8.)).pt(px(0.)).children(rows.into_iter().enumerate().map(|(ix, row)| { let base = h_flex() .id(ElementId::NamedInteger(format!("change-{end}").into(), ix as u64)) .py(px(6.)) .pr(px(8.)) .gap(px(8.)) .rounded(px(8.)) .cursor_pointer() .hover(|style| style.bg(p.row_hover)); match row { Row::Dir { path, name, depth, open, added, removed } => { let session = session.clone(); base.pl(px(8. + depth as f32 * 14.)) .on_click(move |_, _, cx| { session.update(cx, |session, cx| session.toggle_dir(end, path.clone(), cx)) }) .child( Icon::new(if open { IconName::ChevronDown } else { IconName::ChevronRight }) .size(px(12.)) .text_color(p.ink3), ) .child( Icon::new(if open { IconName::FolderOpen } else { IconName::Folder }) .size(px(14.)) .text_color(p.ink3), ) .child( div() .flex_1() .min_w_0() .truncate() .font_family(metal::MONO) .text_size(px(12.5)) .text_color(p.ink2) .child(name), ) .when(!open, |this| this.child(counts(Some(added), Some(removed), p))) .into_any_element() } Row::File { change, name, depth } => { let on_open = on_open.clone(); let path = change.path.clone(); base.pl(px(8. + depth as f32 * 14.)) .on_click(move |_, window, cx| on_open(Some(path.clone()), window, cx)) .child(file_icon(&name).size(px(14.)).text_color(p.ink3)) .child( div() .flex_1() .min_w_0() .truncate() .font_family(metal::MONO) .text_size(px(12.5)) .text_color(p.ink) .child(name), ) .child(counts(change.added, change.removed, p)) .into_any_element() } } }))) .into_any_element() } /// The diff view's state: which turn and file it shows, how, and the diff /// once git has produced it. #[derive(Default)] pub struct DiffView { pub title: SharedString, /// The turn it shows, by the index of its end in the transcript. pub end: usize, /// The one file it was opened for; `None` for the whole turn. pub path: Option, pub files: Option, String>>, pub ignore_whitespace: bool, pub side_by_side: bool, /// Paths of the files folded down to their header. pub collapsed: HashSet, /// Fold every file once the first diff arrives. pub start_collapsed: bool, } impl DiffView { pub fn set_files(&mut self, files: Result, String>) { if let Ok(files) = &files && std::mem::take(&mut self.start_collapsed) { self.collapsed = files.iter().map(|file| file.path.clone()).collect(); } self.files = Some(files); } pub fn toggle_file(&mut self, path: String) { if !self.collapsed.remove(&path) { self.collapsed.insert(path); } } pub fn set_all_collapsed(&mut self, collapsed: bool) { self.collapsed = match &self.files { Some(Ok(files)) if collapsed => files.iter().map(|file| file.path.clone()).collect(), _ => HashSet::new(), }; } } /// What the diff view's controls ask of whoever holds the view. #[derive(Clone, Debug, PartialEq)] pub enum DiffAction { Close, ToggleWhitespace, SetSideBySide(bool), /// Fold or unfold one file, by path. ToggleFile(String), SetAllCollapsed(bool), } /// One row of the side-by-side layout. #[derive(Debug)] enum SplitRow<'a> { /// A hunk header, across both columns. Hunk(&'a DiffLine), /// The old line on the left, the new one on the right; either may be /// missing where one side of a change runs longer. Pair(Option<&'a DiffLine>, Option<&'a DiffLine>), } /// Pairs a file's lines up for the side-by-side layout: context on both /// sides, and each run of removed lines next to the added run that follows it. fn split_rows(lines: &[DiffLine]) -> Vec> { let mut rows = Vec::new(); let mut ix = 0; while ix < lines.len() { let line = &lines[ix]; match line.kind { LineKind::Hunk => rows.push(SplitRow::Hunk(line)), LineKind::Context => rows.push(SplitRow::Pair(Some(line), Some(line))), LineKind::Removed | LineKind::Added => { let run = |from: usize, kind: LineKind| { lines[from..].iter().take_while(|line| line.kind == kind).count() }; let removed = run(ix, LineKind::Removed); let added = run(ix + removed, LineKind::Added); let (old, new) = (&lines[ix..ix + removed], &lines[ix + removed..ix + removed + added]); rows.extend((0..removed.max(added)).map(|row| SplitRow::Pair(old.get(row), new.get(row)))); ix += removed + added; continue; } } ix += 1; } rows } /// A line's sign, background and ink. fn line_style(kind: LineKind, p: &Palette) -> (&'static str, Option, Hsla) { match kind { LineKind::Added => ("+", Some(p.green_deep.opacity(0.14)), p.ink), LineKind::Removed => ("−", Some(p.red.opacity(0.12)), p.ink), LineKind::Hunk => ("", Some(p.ink.opacity(0.04)), p.ink3), LineKind::Context => (" ", None, p.ink2), } } const DIFF_ROW: Pixels = px(18.); /// A hunk header, spanning the whole width. fn hunk_row(line: &DiffLine, p: &Palette) -> Div { let (_, bg, color) = line_style(line.kind, p); h_flex() .h(DIFF_ROW) .px(px(12.)) .when_some(bg, |this, bg| this.bg(bg)) .text_color(color) .child(div().flex_1().min_w_0().truncate().child(line.text.clone())) } /// A line of code behind its numbers in the gutter, clipped at the edge /// rather than wrapped. fn code_row(line: &DiffLine, numbers: &[Option], gutter: Pixels, p: &Palette) -> Div { let (sign, bg, color) = line_style(line.kind, p); h_flex() .h(DIFF_ROW) .min_w_0() .px(px(12.)) .gap(px(10.)) .when_some(bg, |this, bg| this.bg(bg)) .text_color(color) .children(numbers.iter().map(|number| { div() .flex_none() .w(gutter) .text_right() .text_color(p.ink3.opacity(0.7)) .when_some(*number, |this, number| this.child(number.to_string())) })) .child(div().w(px(10.)).flex_none().text_color(p.ink3).child(sign)) .child(div().flex_1().min_w_0().truncate().child(line.text.clone())) } /// One column of a side-by-side row: a line, or a blank where the other /// side runs longer. fn split_cell(line: Option<&DiffLine>, number: impl Fn(&DiffLine) -> Option, gutter: Pixels, p: &Palette) -> Div { match line { Some(line) => code_row(line, &[number(line)], gutter, p), None => div().h(DIFF_ROW).bg(p.ink.opacity(0.025)), } .flex_1() .overflow_hidden() } pub fn render_diff( view: &DiffView, on_action: impl Fn(DiffAction, &mut Window, &mut App) + 'static, cx: &App, ) -> AnyElement { let p = metal::palette(cx); let on_action = Rc::new(on_action); let on_click = |action: DiffAction| { let on_action = on_action.clone(); move |_: &ClickEvent, window: &mut Window, cx: &mut App| on_action(action.clone(), window, cx) }; let files = match &view.files { Some(Ok(files)) if !files.is_empty() => Some(files), _ => None, }; let any_open = files.is_some_and(|files| files.iter().any(|file| !view.collapsed.contains(&file.path))); let header = h_flex() .flex_none() .h(px(46.)) .px(px(16.)) .gap(px(10.)) .child(Icon::new(Lucide::FileDiff).size(px(15.)).text_color(p.ink2)) .child( div() .flex_1() .min_w_0() .truncate() .text_size(px(13.5)) .font_weight(FontWeight::SEMIBOLD) .text_color(p.ink) .child(view.title.clone()), ) .when(files.is_some(), |this| { this.child( MetalButton::new("diff-toggle-all") .ghost() .compact() .icon(if any_open { Lucide::ChevronsDownUp } else { Lucide::ChevronsUpDown }) .label(if any_open { "Collapse all" } else { "Expand all" }) .on_click(on_click(DiffAction::SetAllCollapsed(any_open))), ) }) .child( MetalButton::new("diff-whitespace") .ghost() .mini() .icon(Lucide::Space) .selected(view.ignore_whitespace) .tooltip(if view.ignore_whitespace { "Show whitespace changes" } else { "Hide whitespace changes" }) .on_click(on_click(DiffAction::ToggleWhitespace)), ) .child(metal::segmented("diff-layout", &[(false, "Stacked"), (true, "Side by side")], view.side_by_side, p, { let on_action = on_action.clone(); move |side_by_side, window, cx| on_action(DiffAction::SetSideBySide(side_by_side), window, cx) })) .child(metal::vrule(p)) .child(metal::keycap("Esc", p)) .child( MetalButton::new("close-diff") .ghost() .mini() .icon(IconName::Close) .tooltip("Close diff") .on_click(on_click(DiffAction::Close)), ); let message = |text: SharedString, color: Hsla| { div().p(px(20.)).text_size(px(13.)).text_color(color).child(text).into_any_element() }; let body: AnyElement = match &view.files { None => message("Reading the diff…".into(), p.ink3), Some(Err(error)) => message(format!("Couldn't read the diff: {error}").into(), p.red), Some(Ok(files)) if files.is_empty() => message( if view.ignore_whitespace { "Only whitespace changed." } else { "Nothing changed." }.into(), p.ink3, ), Some(Ok(files)) => { let mut budget = MAX_DIFF_LINES; v_flex() .gap(px(14.)) .p(px(16.)) .children(files.iter().enumerate().map(|(ix, file)| { let open = !view.collapsed.contains(&file.path); let shown = if open { file.lines.len().min(budget) } else { 0 }; budget -= shown; let hidden = file.lines.len() - shown; let lines = &file.lines[..shown]; let added = file.lines.iter().filter(|line| line.kind == LineKind::Added).count() as u64; let removed = file.lines.iter().filter(|line| line.kind == LineKind::Removed).count() as u64; // Wide enough for the longest number, in Geist Mono's 12px digits. let digits = lines.iter().filter_map(|line| line.old_line.max(line.new_line)).max().unwrap_or(0); let gutter = px(digits.to_string().len() as f32 * 7.5); v_flex() .gap(px(6.)) .child( h_flex() .id(("diff-file", ix)) .h(px(28.)) .px(px(8.)) .gap(px(8.)) .rounded(px(9.)) .cursor_pointer() .hover(|style| style.bg(p.row_hover)) .on_click(on_click(DiffAction::ToggleFile(file.path.clone()))) .child( Icon::new(if open { IconName::ChevronDown } else { IconName::ChevronRight }) .size(px(12.)) .text_color(p.ink3), ) .child(file_icon(&file.path).size(px(14.)).text_color(p.ink3)) .child( div() .flex_1() .min_w_0() .truncate() .font_family(metal::MONO) .text_size(px(12.5)) .text_color(p.ink) .child(file.path.clone()), ) .child(counts( (!file.binary).then_some(added), (!file.binary).then_some(removed), p, )), ) .when(open, |this| { this.child( v_flex() .rounded(px(12.)) .well(p) .py(px(6.)) .overflow_hidden() .font_family(metal::MONO) .text_size(px(12.)) .line_height(DIFF_ROW) .when(file.binary, |this| { this.child(div().px(px(12.)).text_color(p.ink3).child("Binary file")) }) .map(|this| { if view.side_by_side { this.children(split_rows(lines).into_iter().map(|row| match row { SplitRow::Hunk(line) => hunk_row(line, p), SplitRow::Pair(old, new) => h_flex() .child( split_cell(old, |line| line.old_line, gutter, p) .border_r_1() .border_color(p.rule), ) .child(split_cell(new, |line| line.new_line, gutter, p)), })) } else { this.children(lines.iter().map(|line| match line.kind { LineKind::Hunk => hunk_row(line, p), _ => code_row(line, &[line.old_line, line.new_line], gutter, p), })) } }) .when(hidden > 0, |this| { this.child( div() .px(px(12.)) .pt(px(4.)) .text_color(p.ink3) .child(format!("… {hidden} more lines")), ) }), ) }) })) .into_any_element() } }; v_flex() .size_full() .child(header) .child(metal::rule(p)) .child(div().id("diff-scroll").flex_1().min_h_0().overflow_y_scroll().child(body)) .into_any_element() } #[cfg(test)] mod tests { use super::{SplitRow, split_rows}; use crate::git::{DiffLine, LineKind}; fn line(kind: LineKind, text: &str) -> DiffLine { DiffLine { kind, text: text.to_string(), old_line: None, new_line: None } } /// The rows as `(left, right)` texts; hunk headers show on both sides. fn texts<'a>(rows: &[SplitRow<'a>]) -> Vec<(Option<&'a str>, Option<&'a str>)> { rows.iter() .map(|row| match row { SplitRow::Hunk(line) => (Some(line.text.as_str()), Some(line.text.as_str())), SplitRow::Pair(old, new) => (old.map(|line| line.text.as_str()), new.map(|line| line.text.as_str())), }) .collect() } #[::core::prelude::v1::test] fn pairs_changes_side_by_side() { use LineKind::*; let lines = [ line(Hunk, "@@"), line(Context, "a"), line(Removed, "b"), line(Removed, "c"), line(Added, "B"), line(Context, "d"), line(Removed, "e"), line(Added, "E"), line(Added, "F"), line(Added, "G"), line(Added, "H"), line(Context, "i"), line(Removed, "j"), ]; assert_eq!( texts(&split_rows(&lines)), [ (Some("@@"), Some("@@")), (Some("a"), Some("a")), (Some("b"), Some("B")), (Some("c"), None), (Some("d"), Some("d")), (Some("e"), Some("E")), (None, Some("F")), (None, Some("G")), (None, Some("H")), (Some("i"), Some("i")), (Some("j"), None), ] ); } #[::core::prelude::v1::test] fn pairs_additions_alone() { let lines = [line(LineKind::Added, "x"), line(LineKind::Removed, "y")]; assert_eq!(texts(&split_rows(&lines)), [(None, Some("x")), (Some("y"), None)]); } }