//! 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<String, Dir>,
files: Vec<FileChange>,
}
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<String> {
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<Row>) {
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<u64>, removed: Option<u64>, 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<Session>,
end: usize,
changes: &TurnChanges,
on_open: impl Fn(Option<String>, &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<String>,
pub files: Option<Result<Vec<FileDiff>, String>>,
pub ignore_whitespace: bool,
pub side_by_side: bool,
/// Paths of the files folded down to their header.
pub collapsed: HashSet<String>,
/// Fold every file once the first diff arrives.
pub start_collapsed: bool,
}
impl DiffView {
pub fn set_files(&mut self, files: Result<Vec<FileDiff>, 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<SplitRow<'_>> {
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>, 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<u32>], 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<u32>, 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)]);
}
}