use std::rc::Rc; use gpui::{ AnyElement, App, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, }; use super::badges::GitStatusBadge; use crate::{ buttons::{Button, ButtonVariant, IconButton}, forms::{Checkbox, Input, Submit, TextInput}, i18n, lists::GitStatus, primitives::IconName, theme::{ActiveTheme, ControlSize, Radius, TextSize}, typography::{Ellipsis, tabular}, }; /// A changed file: its path, its status, and the lines it added and removed. #[derive(Clone, Debug, PartialEq)] pub struct Changed { pub path: SharedString, pub status: GitStatus, /// Added and removed; None when not counted, as for a binary file or a folder. pub lines: Option<(usize, usize)>, } /// What a changes list asks for a file. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ChangeAction { Stage, Unstage, Discard, Open, } type OnAction = Rc; type OnAll = Rc; /// Which part of the work tree a file is listed in. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Section { Conflicted, Staged, Unstaged, Untracked, Ignored, } impl Section { fn key(self) -> &'static str { match self { Self::Conflicted => "git.changes.conflicted", Self::Staged => "git.changes.staged", Self::Unstaged => "git.changes.unstaged", Self::Untracked => "git.changes.untracked", Self::Ignored => "git.changes.ignored", } } } /// Files changed in the work tree, kept apart as conflicted, staged, changed, untracked and ignored: each with its status, its folder and what it changed; open, discard, stage or unstage it, or all at once. #[derive(IntoElement)] pub struct ChangesList { id: ElementId, conflicted: Vec, staged: Vec, unstaged: Vec, untracked: Vec, ignored: Vec, on_action: Option, on_all: Option, } impl ChangesList { pub fn new( id: impl Into, staged: impl IntoIterator, unstaged: impl IntoIterator, ) -> Self { Self { id: id.into(), conflicted: Vec::new(), staged: staged.into_iter().collect(), unstaged: unstaged.into_iter().collect(), untracked: Vec::new(), ignored: Vec::new(), on_action: None, on_all: None, } } /// Files both sides changed; listed first, opened only. pub fn conflicted(mut self, files: impl IntoIterator) -> Self { self.conflicted = files.into_iter().collect(); self } /// Files Git does not track yet; staged or discarded one by one. pub fn untracked(mut self, files: impl IntoIterator) -> Self { self.untracked = files.into_iter().collect(); self } /// Files Git ignores; listed last, opened only. pub fn ignored(mut self, files: impl IntoIterator) -> Self { self.ignored = files.into_iter().collect(); self } pub fn on_action( mut self, handler: impl Fn(&SharedString, ChangeAction, &mut Window, &mut App) + 'static, ) -> Self { self.on_action = Some(Rc::new(handler)); self } /// Called with true to stage every change, false to unstage every staged one. pub fn on_all(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self { self.on_all = Some(Rc::new(handler)); self } } impl ChangesList { fn row(&self, file: &Changed, section: Section, cx: &App) -> AnyElement { let theme = cx.theme(); let colors = theme.colors.clone(); let (folder, name) = match file.path.rsplit_once('/') { Some((folder, name)) => (folder.to_string(), name.to_string()), None => (String::new(), file.path.to_string()), }; let group = SharedString::from(format!("change-{section:?}-{}", file.path)); let action = |key: &str, icon, words: &str, act: ChangeAction| { let words = i18n::text(cx, words, &[]); let (on_action, path) = (self.on_action.clone(), file.path.clone()); IconButton::new( (self.id.clone(), format!("{key}-{section:?}-{}", file.path)), icon, ) .variant(ButtonVariant::Ghost) .size(ControlSize::Sm) .tooltip(words) .when_some(on_action, |button, on_action| { button.on_click(move |_, window, cx| { cx.stop_propagation(); on_action(&path, act, window, cx) }) }) }; let (open, path) = (self.on_action.clone(), file.path.clone()); div() .id((self.id.clone(), format!("row-{section:?}-{}", file.path))) .group(group.clone()) .flex() .items_center() .gap_2() .px_2() .py_1() .rounded(theme.radius(Radius::Sm)) .cursor_pointer() .hover(|row| row.bg(colors.hover)) .when_some(open, |row, open| { row.on_click(move |_, window, cx| open(&path, ChangeAction::Open, window, cx)) }) .child(GitStatusBadge::new( (self.id.clone(), format!("status-{section:?}-{}", file.path)), file.status, )) .child( div() .flex_1() .min_w_0() .flex() .items_baseline() .gap_2() .child( div() .flex_none() .text_color(colors.fg) .when(file.status == GitStatus::Deleted, |name| { name.line_through() }) .child(name), ) .child( div() .flex_1() .min_w_0() .text_color(colors.fg_subtle) .child(Ellipsis::new(folder)), ), ) .child( div() .flex() .opacity(0.) .group_hover(group, |actions| actions.opacity(1.)) .child(action( "open", IconName::FileText, "git.changes.open", ChangeAction::Open, )) .when( matches!(section, Section::Unstaged | Section::Untracked), |actions| { actions.child(action( "discard", IconName::RotateCcw, "git.changes.discard", ChangeAction::Discard, )) }, ) .when(section == Section::Staged, |actions| { actions.child(action( "unstage", IconName::Minus, "git.changes.unstage", ChangeAction::Unstage, )) }) .when( matches!(section, Section::Unstaged | Section::Untracked), |actions| { actions.child(action( "stage", IconName::Plus, "git.changes.stage", ChangeAction::Stage, )) }, ), ) .children(file.lines.map(|(added, removed)| { tabular(div().flex_none().text_color(colors.fg_subtle)) .child(format!("+{added} −{removed}")) })) .into_any_element() } fn section(&self, section: Section, files: &[Changed], cx: &App) -> Option { if files.is_empty() { return None; } let theme = cx.theme(); let colors = theme.colors.clone(); // Only staged and changed files move all at once. let all = match section { Section::Staged => Some((IconName::Minus, "git.changes.unstage_all", false)), Section::Unstaged => Some((IconName::Plus, "git.changes.stage_all", true)), _ => None, }; let on_all = self.on_all.clone(); let button = all.map(|(icon, key, stage)| { IconButton::new((self.id.clone(), format!("all-{section:?}")), icon) .variant(ButtonVariant::Ghost) .size(ControlSize::Sm) .tooltip(i18n::text(cx, key, &[])) .when_some(on_all, |button, on_all| { button.on_click(move |_, window, cx| on_all(stage, window, cx)) }) }); Some( div() .flex() .flex_col() .child( div() .flex() .items_center() .gap_2() .px_2() .py_1() .text_size(theme.text_size(TextSize::Xs)) .text_color(colors.fg_muted) .child( div() .flex_1() .font_weight(FontWeight::MEDIUM) .child(i18n::text(cx, section.key(), &[])), ) .child(tabular(div()).child(files.len().to_string())) .children(button), ) .children(files.iter().map(|file| self.row(file, section, cx))) .into_any_element(), ) } } impl RenderOnce for ChangesList { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); let sections = [ self.section(Section::Conflicted, &self.conflicted, cx), self.section(Section::Staged, &self.staged, cx), self.section(Section::Unstaged, &self.unstaged, cx), self.section(Section::Untracked, &self.untracked, cx), self.section(Section::Ignored, &self.ignored, cx), ]; div() .flex() .flex_col() .gap_2() .text_size(theme.text_size(TextSize::Sm)) .children(sections.into_iter().flatten()) } } /// Characters a subject reads well within, and the most it should take. const SUBJECT: (usize, usize) = (50, 72); type Run = Rc; /// A commit message: its subject counted against fifty, a body, and the commit button, which waits for a subject and something staged. Cmd-Enter commits; amend takes the last commit's place. #[derive(IntoElement)] pub struct CommitInput { id: ElementId, subject: Entity, body: Entity, branch: SharedString, staged: usize, amend: bool, on_amend: Option, on_commit: Option, } impl CommitInput { pub fn new( id: impl Into, subject: &Entity, body: &Entity, branch: impl Into, staged: usize, ) -> Self { Self { id: id.into(), subject: subject.clone(), body: body.clone(), branch: branch.into(), staged, amend: false, on_amend: None, on_commit: None, } } pub fn amend(mut self, amend: bool) -> Self { self.amend = amend; self } pub fn on_amend(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self { self.on_amend = Some(Rc::new(handler)); self } pub fn on_commit(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { self.on_commit = Some(Rc::new(handler)); self } } impl RenderOnce for CommitInput { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); let colors = theme.colors.clone(); let length = self.subject.read(cx).text().chars().count(); let ready = length > 0 && (self.staged > 0 || self.amend); let count = match length { length if length > SUBJECT.1 => colors.danger, length if length > SUBJECT.0 => colors.warning, _ => colors.fg_subtle, }; let commit = self.on_commit.filter(|_| ready); let submit = commit.clone(); let label = if self.amend { format!("Amend {}", self.branch) } else { format!("Commit to {}", self.branch) }; let amend = Checkbox::new((self.id.clone(), "amend"), self.amend).label("Amend last commit"); let amend = match self.on_amend { Some(on_amend) => amend.on_change(move |on, window, cx| on_amend(on, window, cx)), None => amend, }; div() .id(self.id.clone()) .flex() .flex_col() .gap_2() .text_size(theme.text_size(TextSize::Sm)) .capture_action(move |_: &Submit, window, cx| { if let Some(submit) = &submit { cx.stop_propagation(); submit(window, cx) } }) .child( Input::new(&self.subject).suffix( tabular(div().text_color(count)).child(format!("{length}/{}", SUBJECT.0)), ), ) .child(Input::new(&self.body)) .child( div() .flex() .items_center() .gap_2() .child(div().flex_1().child(amend)) .child( Button::new((self.id.clone(), "commit"), label) .variant(ButtonVariant::Primary) .size(ControlSize::Sm) .disabled(!ready) .when_some(commit, |button, commit| { button.on_click(move |_, window, cx| commit(window, cx)) }), ), ) } }