1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20#[non_exhaustive]
21pub enum FileState {
22 Added,
24 Modified,
26 Deleted,
28 Renamed,
30 Untracked,
32 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 #[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 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#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct GitEntry {
74 pub path: String,
76 pub index: Option<FileState>,
78 pub worktree: Option<FileState>,
80 pub before: Option<String>,
82 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#[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#[must_use]
123pub fn ordered(entries: &[GitEntry]) -> Vec<GitEntry> {
124 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#[component]
137pub fn GitSurface(
138 branch: Option<String>,
140 entries: Vec<GitEntry>,
141 #[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 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 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 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}