Vectrum
Sign In
647 lines · 25.8 KB · Rust
//! 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)) => {
                let ink = metal::diff_ink(p);
                this.child(div().text_color(ink.added).child(format!("+{}", format_count(added))))
                    .child(div().text_color(ink.removed).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(metal::diff_ink(p).added_bg), p.ink),
        LineKind::Removed => ("−", Some(metal::diff_ink(p).removed_bg), 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)]);
    }
}