//! 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<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()
.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<Result<Vec<FileDiff>, 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()
}