Vectrum
Sign In
376 lines · 15.3 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 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()
}