Skip to main content

fc_dioxus/components/
git.rs

1//! A working-tree view: branch, status counts, changed files, and per-file line stats,
2//! from git.css, on upstream's `fc-git` model.
3//!
4//! It runs no Git and parses no porcelain: it is a view over status entries the
5//! application already has, from libgit2, a binding, or anywhere else.
6use crate::{
7    core::{
8        assets,
9        attrs::{component_attributes, GitData},
10    },
11    models::git as model,
12    prelude::*,
13    theme::use_stylesheets,
14};
15use std::rc::Rc;
16const BEM: Bem = Bem::new("fc-git");
17
18/// Where a file is, on one side (index or worktree).
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20#[non_exhaustive]
21pub enum FileState {
22    /// New.
23    Added,
24    /// Changed.
25    Modified,
26    /// Removed.
27    Deleted,
28    /// Moved.
29    Renamed,
30    /// Not tracked yet.
31    Untracked,
32    /// Merge conflict.
33    Conflicted,
34}
35impl FileState {
36    const fn code(self) -> u8 {
37        match self {
38            Self::Added => 0,
39            Self::Modified => 1,
40            Self::Deleted => 2,
41            Self::Renamed => 3,
42            Self::Untracked => 4,
43            Self::Conflicted => 5,
44        }
45    }
46    /// The state's name.
47    #[must_use]
48    pub const fn as_str(self) -> &'static str {
49        match self {
50            Self::Added => "added",
51            Self::Modified => "modified",
52            Self::Deleted => "deleted",
53            Self::Renamed => "renamed",
54            Self::Untracked => "untracked",
55            Self::Conflicted => "conflicted",
56        }
57    }
58    /// The glyph drawn under forced colors, where the badge colour is discarded.
59    const fn glyph(self) -> &'static str {
60        match self {
61            Self::Added => "+",
62            Self::Modified => "~",
63            Self::Deleted => "−",
64            Self::Renamed => ">",
65            Self::Untracked => "?",
66            Self::Conflicted => "!",
67        }
68    }
69}
70
71/// One changed file.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct GitEntry {
74    /// Repository-relative path.
75    pub path: String,
76    /// State in the index (staged), if any.
77    pub index: Option<FileState>,
78    /// State in the worktree (not staged), if any.
79    pub worktree: Option<FileState>,
80    /// Contents before, when loaded; with `after`, gives the line stat.
81    pub before: Option<String>,
82    /// Contents after, when loaded.
83    pub after: Option<String>,
84}
85impl GitEntry {
86    fn conflicted(&self) -> bool {
87        self.index == Some(FileState::Conflicted) || self.worktree == Some(FileState::Conflicted)
88    }
89    fn modeled(&self) -> model::Entry {
90        model::Entry {
91            index: self.index.map_or(255, FileState::code),
92            worktree: self.worktree.map_or(255, FileState::code),
93        }
94    }
95}
96
97/// "1 conflicted · 2 staged · 3 not staged · 1 untracked", or "Working tree clean".
98#[must_use]
99pub fn summary(entries: &[GitEntry]) -> String {
100    let counts =
101        model::summarize_status(&entries.iter().map(GitEntry::modeled).collect::<Vec<_>>());
102    let mut parts = Vec::new();
103    for (count, label) in [
104        (counts.conflicted, "conflicted"),
105        (counts.staged, "staged"),
106        (counts.unstaged, "not staged"),
107        (counts.untracked, "untracked"),
108    ] {
109        if count > 0 {
110            parts.push(format!("{count} {label}"));
111        }
112    }
113    if parts.is_empty() {
114        "Working tree clean".to_owned()
115    } else {
116        parts.join(" · ")
117    }
118}
119
120/// Entries in the order that needs attention first: conflicts, then files changed on
121/// both sides, then the rest, each group by path.
122#[must_use]
123pub fn ordered(entries: &[GitEntry]) -> Vec<GitEntry> {
124    // fc-git sorts by rank only and requires path order going in.
125    let mut by_path = entries.to_vec();
126    by_path.sort_by(|a, b| a.path.cmp(&b.path));
127    let modeled: Vec<model::Entry> = by_path.iter().map(GitEntry::modeled).collect();
128    model::order_entries(&modeled)
129        .into_iter()
130        .filter_map(|i| by_path.get(usize::try_from(i).ok()?).cloned())
131        .collect()
132}
133
134/// The branch, the status counts, and the changed files as a grid with one tab stop.
135/// Arrows, Home, and End move the selection; a Stage button sits on each unstaged file.
136#[component]
137pub fn GitSurface(
138    /// The branch, or `None` before the first commit.
139    branch: Option<String>,
140    entries: Vec<GitEntry>,
141    /// Names the file grid; "Changed files" when omitted.
142    #[props(default)]
143    label: Option<AccessibleName>,
144    #[props(default)] onselect: Option<EventHandler<String>>,
145    #[props(default)] onstage: Option<EventHandler<String>>,
146    #[props(default)] class: Option<String>,
147    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
148) -> Element {
149    let rows = ordered(&entries);
150    let first = rows.first().map(|e| e.path.clone());
151    let selected = use_signal(|| first);
152    let mounted = use_signal(Vec::<(String, Rc<MountedData>)>::new);
153    let base = use_id();
154    let counts =
155        model::summarize_status(&entries.iter().map(GitEntry::modeled).collect::<Vec<_>>());
156    let mut attributes = component_attributes(
157        attributes,
158        ClassList::new(BEM.block()).extend(class.as_deref()),
159        &[],
160    );
161    attributes.extend([
162        GitData::Branch.attribute(&branch.clone().unwrap_or_default()),
163        GitData::BranchState.attribute(if branch.is_some() { "named" } else { "unborn" }),
164        GitData::Staged.attribute(&counts.staged),
165        GitData::Unstaged.attribute(&counts.unstaged),
166        GitData::Conflicted.attribute(&counts.conflicted),
167        GitData::Untracked.attribute(&counts.untracked),
168    ]);
169    if rows.is_empty() {
170        attributes.push(GitData::Empty.attribute(&"true"));
171    }
172    let base = base.to_string();
173    let label = label.map_or_else(|| "Changed files".to_owned(), |l| l.to_string());
174    let empty = rows.is_empty();
175    let paths: Vec<String> = rows.iter().map(|e| e.path.clone()).collect();
176    rsx! {
177        if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_GIT_CSS } }
178        div { ..attributes,
179            div { class: BEM.element("header"),
180                // A repository with no commits has a branch and no history.
181                span { class: BEM.element("branch"), "{branch.clone().unwrap_or_else(|| \"Initial commit\".to_owned())}" }
182                span { class: BEM.element("summary"), "{summary(&entries)}" }
183            }
184            div { class: BEM.element("files"), role: if empty { "group" } else { "grid" }, aria_label: "{label}",
185                if empty {
186                    p { class: BEM.element("empty"), "No changes" }
187                }
188                for (index, entry) in rows.into_iter().enumerate() {
189                    {row_view(&entry, index, &base, selected, mounted, paths.clone(), onselect, onstage)}
190                }
191            }
192        }
193    }
194}
195
196#[allow(clippy::too_many_arguments)]
197fn row_view(
198    entry: &GitEntry,
199    index: usize,
200    base: &str,
201    mut selected: Signal<Option<String>>,
202    mut mounted: Signal<Vec<(String, Rc<MountedData>)>>,
203    paths: Vec<String>,
204    onselect: Option<EventHandler<String>>,
205    onstage: Option<EventHandler<String>>,
206) -> Element {
207    let base = base.to_owned();
208    let id = row_id(&base, index);
209    let on = selected().as_deref() == Some(entry.path.as_str());
210    let (dir, base) = entry
211        .path
212        .rsplit_once('/')
213        .map_or((String::new(), entry.path.clone()), |(d, b)| {
214            (format!("{d}/"), b.to_owned())
215        });
216    let conflicted = entry.conflicted();
217    let stat = (entry.before.is_some() || entry.after.is_some())
218        .then(|| model::diff_counts(entry.before.as_deref(), entry.after.as_deref()))
219        .filter(|(added, removed)| *added > 0 || *removed > 0);
220    let path = entry.path.clone();
221    let mut choose = move |path: String| {
222        selected.set(Some(path.clone()));
223        if let Some(onselect) = onselect {
224            onselect.call(path);
225        }
226    };
227    let data = crate::spread![
228        GitData::Path.attribute(&entry.path),
229        GitData::Selected.attribute(&on),
230    ];
231    rsx! {
232        div { key: "{entry.path}", class: BEM.element("row"), id: "{id}", role: "row", aria_selected: on.to_string(),
233            tabindex: if on { "0" } else { "-1" },
234            onclick: { let path = path.clone(); move |_| choose(path.clone()) },
235            onmounted: { let path = path.clone(); move |event: MountedEvent| {
236                let mut nodes = mounted.write();
237                nodes.retain(|(p, _)| *p != path);
238                nodes.push((path.clone(), event.data()));
239            } },
240            onkeydown: move |event| {
241                let last = paths.len().saturating_sub(1);
242                let next = match event.key() {
243                    Key::ArrowDown => Some((index + 1).min(last)),
244                    Key::ArrowUp => Some(index.saturating_sub(1)),
245                    Key::Home => Some(0),
246                    Key::End => Some(last),
247                    _ => None,
248                };
249                if let Some(next) = next {
250                    event.prevent_default();
251                    if let Some(target) = paths.get(next) {
252                        choose(target.clone());
253                        let node = mounted.peek().iter().find(|(p, _)| p == target).map(|(_, m)| m.clone());
254                        if let Some(node) = node {
255                            spawn(async move { let _ = node.set_focus(true).await; });
256                        }
257                    }
258                } else if matches!(event.key(), Key::Enter) || event.key() == Key::Character(" ".into()) {
259                    event.prevent_default();
260                    choose(path.clone());
261                }
262            },
263            ..data,
264            span { class: BEM.element("file-name"), role: "gridcell",
265                if !dir.is_empty() { span { class: BEM.element("file-dir"), "{dir}" } }
266                "{base}"
267            }
268            span { class: BEM.element("stages"), role: "gridcell",
269                if let Some(state) = entry.index { {badge("index", state, conflicted)} }
270                if let Some(state) = entry.worktree { {badge("worktree", state, conflicted)} }
271                if entry.index.is_none() && !conflicted {
272                    button { r#type: "button", class: BEM.element("stage-button"), aria_label: "Stage {entry.path}",
273                        onclick: { let path = entry.path.clone(); move |event: MouseEvent| { event.stop_propagation(); if let Some(onstage) = onstage { onstage.call(path.clone()); } } },
274                        // The row's keys would cancel the button's own Enter and Space.
275                        onkeydown: move |event: KeyboardEvent| event.stop_propagation(),
276                        ..crate::spread![GitData::StagePath.attribute(&entry.path)],
277                        "Stage"
278                    }
279                }
280            }
281            span { class: BEM.element("stat"), role: "gridcell", hidden: stat.is_none(),
282                if let Some((added, removed)) = stat {
283                    span { class: BEM.element("stat-add"), "+{added}" }
284                    span { class: BEM.element("stat-remove"), "−{removed}" }
285                }
286            }
287        }
288    }
289}
290
291fn badge(side: &'static str, state: FileState, conflicted: bool) -> Element {
292    let shown = if conflicted {
293        FileState::Conflicted
294    } else {
295        state
296    };
297    let place = if side == "index" {
298        "staged"
299    } else {
300        "not staged"
301    };
302    // The side is in the title too: "modified" alone does not say whether it is staged.
303    let title = format!("{} ({place})", shown.as_str());
304    let data = crate::spread![
305        GitData::Stage.attribute(&side),
306        GitData::State.attribute(&shown.as_str()),
307        GitData::Glyph.attribute(&shown.glyph()),
308    ];
309    rsx! { span { class: BEM.element("badge"), title, ..data, "{state.as_str()}" } }
310}
311
312fn row_id(base: &str, index: usize) -> String {
313    format!("{base}-row-{index}")
314}