//! 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 gpui_kit::component::{Icon, IconName, h_flex, v_flex}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use crate::git::{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() .rounded(px(14.)) .raised(p) .child( h_flex() .h(px(42.)) .pl(px(14.)) .pr(px(6.)) .gap(px(10.)) .child( div() .text_size(px(13.5)) .font_weight(FontWeight::SEMIBOLD) .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 folders" } else { "Expand folders" }) .on_click(toggle_all), ) }) .child( MetalButton::new(("changes-open-diff", end)) .ghost() .compact() .icon(Lucide::FileDiff) .label("Open diff") .on_click(open_all), ), ) .child(metal::rule(p)) .child(v_flex().p(px(6.)).gap(px(1.)).children(rows.into_iter().enumerate().map(|(ix, row)| { let base = h_flex() .id(ElementId::NamedInteger(format!("change-{end}").into(), ix as u64)) .h(px(30.)) .pr(px(8.)) .gap(px(8.)) .rounded(px(9.)) .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 * 16.)) .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(28. + depth as f32 * 16.)) .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, and the diff once /// git has produced it. pub struct DiffView { pub title: SharedString, pub files: Option, String>>, } pub fn render_diff(view: &DiffView, on_close: impl Fn(&mut Window, &mut App) + 'static, cx: &App) -> AnyElement { let p = metal::palette(cx); 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()), ) .child(metal::keycap("Esc", p)) .child( MetalButton::new("close-diff") .ghost() .mini() .icon(IconName::Close) .tooltip("Close diff") .on_click(move |_, window, cx| on_close(window, cx)), ); let body: AnyElement = match &view.files { None => div().p(px(20.)).text_size(px(13.)).text_color(p.ink3).child("Reading the diff…").into_any_element(), Some(Err(error)) => div() .p(px(20.)) .text_size(px(13.)) .text_color(p.red) .child(format!("Couldn't read the diff: {error}")) .into_any_element(), Some(Ok(files)) => { let mut budget = MAX_DIFF_LINES; v_flex() .gap(px(14.)) .p(px(16.)) .children(files.iter().map(|file| { let shown = file.lines.len().min(budget); budget -= shown; let hidden = file.lines.len() - 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; v_flex() .gap(px(6.)) .child( h_flex() .gap(px(10.)) .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, )), ) .child( v_flex() .rounded(px(12.)) .well(p) .py(px(6.)) .font_family(metal::MONO) .text_size(px(12.)) .line_height(px(18.)) .when(file.binary, |this| { this.child(div().px(px(12.)).text_color(p.ink3).child("Binary file")) }) .children(file.lines.iter().take(shown).map(|line| { let (sign, bg, color) = match line.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), }; h_flex() .px(px(12.)) .gap(px(10.)) .when_some(bg, |this, bg| this.bg(bg)) .text_color(color) .child(div().w(px(10.)).flex_none().text_color(p.ink3).child(sign)) .child(div().flex_1().min_w_0().whitespace_nowrap().child(line.text.clone())) })) .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() }