1use std::cell::RefCell;
8use std::collections::{HashMap, HashSet};
9
10use super::{
11 BlobPair, BranchInfo, ChangeStatus, CommitInfo, Diff, DiffLine, DiffLineKind, FileChange,
12 RefKind, RefLabel, RepoBackend, WorkingStatus,
13};
14
15pub struct FileEntry {
17 pub change: FileChange,
18 pub diff: Diff,
19}
20
21struct WorkingEntry {
26 change: FileChange,
27 diff: Diff,
28 staged: bool,
29 blobs: BlobPair,
32}
33
34pub struct FixtureBackend {
35 path: String,
36 commits: Vec<CommitInfo>,
37 files: HashMap<usize, Vec<FileEntry>>,
38 branches: Vec<(BranchInfo, Vec<FileEntry>)>,
41 working: RefCell<Vec<WorkingEntry>>,
43 amend_removed: RefCell<HashSet<String>>,
46 last_commit: RefCell<Option<(String, bool)>>,
48 signature: Option<(String, String)>,
50}
51
52impl FixtureBackend {
53 pub fn new(path: impl Into<String>) -> Self {
54 Self {
55 path: path.into(),
56 commits: Vec::new(),
57 files: HashMap::new(),
58 branches: Vec::new(),
59 working: RefCell::new(Vec::new()),
60 amend_removed: RefCell::new(HashSet::new()),
61 last_commit: RefCell::new(None),
62 signature: Some(("Robert Lillack".to_string(), "rob@example.com".to_string())),
63 }
64 }
65
66 pub fn with_signature(mut self, signature: Option<(String, String)>) -> Self {
68 self.signature = signature;
69 self
70 }
71
72 fn head_files(&self) -> &[FileEntry] {
75 self.files.get(&0).map(Vec::as_slice).unwrap_or(&[])
76 }
77
78 fn branch_entries(&self, branch: &BranchInfo) -> Option<&[FileEntry]> {
80 self.branches
81 .iter()
82 .find(|(info, _)| info.name == branch.name)
83 .map(|(_, entries)| entries.as_slice())
84 }
85
86 pub fn add_commit(&mut self, info: CommitInfo, files: Vec<FileEntry>) -> &mut Self {
88 let idx = self.commits.len();
89 self.commits.push(info);
90 self.files.insert(idx, files);
91 self
92 }
93
94 pub fn add_working(
96 &mut self,
97 path: &str,
98 status: ChangeStatus,
99 staged: bool,
100 diff_lines: &[(DiffLineKind, &str)],
101 ) -> &mut Self {
102 self.working.borrow_mut().push(WorkingEntry {
103 change: FileChange {
104 path: path.to_string(),
105 old_path: None,
106 status,
107 },
108 diff: diff(diff_lines),
109 staged,
110 blobs: BlobPair::default(),
111 });
112 self
113 }
114
115 pub fn add_working_image(
120 &mut self,
121 path: &str,
122 status: ChangeStatus,
123 staged: bool,
124 old: Option<Vec<u8>>,
125 new: Option<Vec<u8>>,
126 ) -> &mut Self {
127 self.working.borrow_mut().push(WorkingEntry {
128 change: FileChange {
129 path: path.to_string(),
130 old_path: None,
131 status,
132 },
133 diff: Diff {
134 lines: vec![DiffLine::new(
135 DiffLineKind::Meta,
136 format!("Binary files a/{path} and b/{path} differ"),
137 )],
138 },
139 staged,
140 blobs: BlobPair { old, new },
141 });
142 self
143 }
144
145 pub fn add_branch(&mut self, info: BranchInfo, files: Vec<FileEntry>) -> &mut Self {
148 self.branches.push((info, files));
149 self
150 }
151
152 pub fn last_commit(&self) -> Option<(String, bool)> {
155 self.last_commit.borrow().clone()
156 }
157
158 pub fn sample() -> Self {
161 let mut be = FixtureBackend::new("/home/rob/dev/journey");
162
163 be.add_commit(
164 commit(
165 "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678",
166 "Add commit DAG graph view",
167 "Robert Lillack",
168 "rob@example.com",
169 1_716_500_000,
170 120,
171 &["b2c3d4e5f60718293a4b5c6d7e8f90123456789a"],
172 &[("main", RefKind::Head)],
173 ),
174 vec![file_entry(
175 "src/widgets/graph.rs",
176 None,
177 ChangeStatus::Added,
178 &[
179 (
180 DiffLineKind::FileHeader,
181 "diff --git a/src/widgets/graph.rs b/src/widgets/graph.rs",
182 ),
183 (DiffLineKind::FileHeader, "new file mode 100644"),
184 (DiffLineKind::HunkHeader, "@@ -0,0 +1,3 @@"),
185 (DiffLineKind::Addition, "+pub struct Graph {"),
186 (DiffLineKind::Addition, "+ lanes: Vec<Lane>,"),
187 (DiffLineKind::Addition, "+}"),
188 ],
189 )],
190 );
191
192 be.add_commit(
193 commit(
194 "b2c3d4e5f60718293a4b5c6d7e8f90123456789a",
195 "Build basic file list per commit",
196 "Robert Lillack",
197 "rob@example.com",
198 1_716_400_000,
199 120,
200 &["c3d4e5f60718293a4b5c6d7e8f90123456789ab2"],
201 &[("v0.2", RefKind::Tag)],
202 ),
203 vec![
204 file_entry(
205 "src/backend.rs",
206 None,
207 ChangeStatus::Modified,
208 &[
209 (
210 DiffLineKind::FileHeader,
211 "diff --git a/src/backend.rs b/src/backend.rs",
212 ),
213 (
214 DiffLineKind::HunkHeader,
215 "@@ -10,6 +10,10 @@ impl Backend {",
216 ),
217 (
218 DiffLineKind::Context,
219 " pub fn log(&self) -> Vec<Commit> {",
220 ),
221 (
222 DiffLineKind::Addition,
223 "+ // collect changed files too",
224 ),
225 (DiffLineKind::Addition, "+ self.changed_files();"),
226 (DiffLineKind::Context, " self.commits.clone()"),
227 (DiffLineKind::Context, " }"),
228 ],
229 ),
230 file_entry(
231 "src/main.rs",
232 None,
233 ChangeStatus::Modified,
234 &[
235 (
236 DiffLineKind::FileHeader,
237 "diff --git a/src/main.rs b/src/main.rs",
238 ),
239 (DiffLineKind::HunkHeader, "@@ -42,7 +42,7 @@"),
240 (DiffLineKind::Deletion, "- let files = vec![];"),
241 (
242 DiffLineKind::Addition,
243 "+ let files = backend.changed_files(idx);",
244 ),
245 ],
246 ),
247 ],
248 );
249
250 be.add_commit(
251 commit(
252 "c3d4e5f60718293a4b5c6d7e8f90123456789ab2",
253 "Show path in title",
254 "Robert Lillack",
255 "rob@example.com",
256 1_716_300_000,
257 120,
258 &["d4e5f60718293a4b5c6d7e8f90123456789ab2c3"],
259 &[],
260 ),
261 vec![file_entry(
262 "src/main.rs",
263 None,
264 ChangeStatus::Modified,
265 &[
266 (
267 DiffLineKind::FileHeader,
268 "diff --git a/src/main.rs b/src/main.rs",
269 ),
270 (DiffLineKind::HunkHeader, "@@ -20,1 +20,1 @@ fn title()"),
271 (DiffLineKind::Deletion, "- String::from(\"Journey\")"),
272 (
273 DiffLineKind::Addition,
274 "+ format!(\"Journey: {}\", path)",
275 ),
276 ],
277 )],
278 );
279
280 be.add_commit(
281 commit(
282 "d4e5f60718293a4b5c6d7e8f90123456789ab2c3",
283 "Rename boldFont() -> bold_font()",
284 "Robert Lillack",
285 "rob@example.com",
286 1_716_200_000,
287 120,
288 &["e5f60718293a4b5c6d7e8f90123456789ab2c3d4"],
289 &[("origin/main", RefKind::RemoteBranch)],
290 ),
291 vec![file_entry(
292 "src/style.rs",
293 None,
294 ChangeStatus::Modified,
295 &[
296 (
297 DiffLineKind::FileHeader,
298 "diff --git a/src/style.rs b/src/style.rs",
299 ),
300 (DiffLineKind::HunkHeader, "@@ -80,4 +80,4 @@"),
301 (DiffLineKind::Deletion, "-pub fn boldFont() -> Font {"),
302 (DiffLineKind::Addition, "+pub fn bold_font() -> Font {"),
303 ],
304 )],
305 );
306
307 be.add_commit(
308 commit(
309 "e5f60718293a4b5c6d7e8f90123456789ab2c3d4",
310 "Initial import",
311 "Robert Lillack",
312 "rob@example.com",
313 1_716_100_000,
314 120,
315 &[],
316 &[],
317 ),
318 vec![
319 file_entry(
320 "Cargo.toml",
321 None,
322 ChangeStatus::Added,
323 &[
324 (
325 DiffLineKind::FileHeader,
326 "diff --git a/Cargo.toml b/Cargo.toml",
327 ),
328 (DiffLineKind::FileHeader, "new file mode 100644"),
329 (DiffLineKind::HunkHeader, "@@ -0,0 +1,2 @@"),
330 (DiffLineKind::Addition, "+[package]"),
331 (DiffLineKind::Addition, "+name = \"journey\""),
332 ],
333 ),
334 file_entry(
335 "src/main.rs",
336 None,
337 ChangeStatus::Added,
338 &[
339 (
340 DiffLineKind::FileHeader,
341 "diff --git a/src/main.rs b/src/main.rs",
342 ),
343 (DiffLineKind::FileHeader, "new file mode 100644"),
344 (DiffLineKind::HunkHeader, "@@ -0,0 +1,1 @@"),
345 (DiffLineKind::Addition, "+fn main() {}"),
346 ],
347 ),
348 ],
349 );
350
351 be.add_working(
354 "src/ui.rs",
355 ChangeStatus::Modified,
356 false,
357 &[
358 (
359 DiffLineKind::FileHeader,
360 "diff --git a/src/ui.rs b/src/ui.rs",
361 ),
362 (
363 DiffLineKind::HunkHeader,
364 "@@ -40,6 +40,9 @@ impl GitClient {",
365 ),
366 (DiffLineKind::Context, " fn sync(&mut self) {"),
367 (
368 DiffLineKind::Addition,
369 "+ // refresh the working-tree panes",
370 ),
371 (DiffLineKind::Addition, "+ self.rescan();"),
372 (DiffLineKind::Context, " self.repaint();"),
373 (DiffLineKind::Context, " }"),
374 ],
375 );
376 be.add_working(
377 "notes.md",
378 ChangeStatus::Untracked,
379 false,
380 &[
381 (DiffLineKind::FileHeader, "diff --git a/notes.md b/notes.md"),
382 (DiffLineKind::FileHeader, "new file mode 100644"),
383 (DiffLineKind::HunkHeader, "@@ -0,0 +1,2 @@"),
384 (DiffLineKind::Addition, "+# Notes"),
385 (DiffLineKind::Addition, "+- wire up commit mode"),
386 ],
387 );
388 be.add_working(
389 "src/widgets/commit_panel.rs",
390 ChangeStatus::Added,
391 true,
392 &[
393 (
394 DiffLineKind::FileHeader,
395 "diff --git a/src/widgets/commit_panel.rs b/src/widgets/commit_panel.rs",
396 ),
397 (DiffLineKind::FileHeader, "new file mode 100644"),
398 (DiffLineKind::HunkHeader, "@@ -0,0 +1,3 @@"),
399 (DiffLineKind::Addition, "+pub struct CommitPanel {"),
400 (DiffLineKind::Addition, "+ message: String,"),
401 (DiffLineKind::Addition, "+}"),
402 ],
403 );
404 be.add_working(
405 "Cargo.toml",
406 ChangeStatus::Modified,
407 true,
408 &[
409 (
410 DiffLineKind::FileHeader,
411 "diff --git a/Cargo.toml b/Cargo.toml",
412 ),
413 (
414 DiffLineKind::HunkHeader,
415 "@@ -8,3 +8,4 @@ edition = \"2024\"",
416 ),
417 (DiffLineKind::Context, " [dependencies]"),
418 (
419 DiffLineKind::Addition,
420 "+git2 = { version = \"0.18\", default-features = false }",
421 ),
422 (
423 DiffLineKind::Context,
424 " saudade = { path = \"../saudade\" }",
425 ),
426 ],
427 );
428
429 be.add_branch(
435 BranchInfo {
436 name: "main".into(),
437 kind: RefKind::Head,
438 tip_id: "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678".into(),
439 summary: "Add commit DAG graph view".into(),
440 author: "Robert Lillack".into(),
441 time_seconds: 1_716_500_000,
442 time_offset_minutes: 120,
443 upstream: Some("origin/main".into()),
444 base_name: "main".into(),
445 base_id: Some("a1b2c3d4e5f60718293a4b5c6d7e8f9012345678".into()),
446 },
447 vec![],
448 );
449 be.add_branch(
450 BranchInfo {
451 name: "feature/list-icons".into(),
452 kind: RefKind::LocalBranch,
453 tip_id: "f60718293a4b5c6d7e8f90123456789ab2c3d4e5".into(),
454 summary: "Bake SVG status markers".into(),
455 author: "Robert Lillack".into(),
456 time_seconds: 1_716_450_000,
457 time_offset_minutes: 120,
458 upstream: None,
459 base_name: "main".into(),
460 base_id: Some("c3d4e5f60718293a4b5c6d7e8f90123456789ab2".into()),
461 },
462 vec![
463 file_entry(
464 "assets/status/added.svg",
465 None,
466 ChangeStatus::Added,
467 &[
468 (
469 DiffLineKind::FileHeader,
470 "diff --git a/assets/status/added.svg b/assets/status/added.svg",
471 ),
472 (DiffLineKind::FileHeader, "new file mode 100644"),
473 (DiffLineKind::HunkHeader, "@@ -0,0 +1,2 @@"),
474 (DiffLineKind::Addition, "+<svg viewBox=\"0 0 12 12\">"),
475 (DiffLineKind::Addition, "+ <rect rx=\"2\" fill=\"#2A2\"/>"),
476 ],
477 ),
478 file_entry(
479 "src/widgets/list.rs",
480 None,
481 ChangeStatus::Modified,
482 &[
483 (
484 DiffLineKind::FileHeader,
485 "diff --git a/src/widgets/list.rs b/src/widgets/list.rs",
486 ),
487 (
488 DiffLineKind::HunkHeader,
489 "@@ -12,5 +12,6 @@ impl ListItem {",
490 ),
491 (
492 DiffLineKind::Context,
493 " pub fn new(text: &str) -> Self {",
494 ),
495 (
496 DiffLineKind::Deletion,
497 "- Self { text: text.into() }",
498 ),
499 (
500 DiffLineKind::Addition,
501 "+ Self { text: text.into(), icon: None }",
502 ),
503 (DiffLineKind::Context, " }"),
504 (DiffLineKind::Context, " }"),
505 ],
506 ),
507 ],
508 );
509 be.add_branch(
510 BranchInfo {
511 name: "origin/font-rename".into(),
512 kind: RefKind::RemoteBranch,
513 tip_id: "d4e5f60718293a4b5c6d7e8f90123456789ab2c3".into(),
514 summary: "Rename boldFont() -> bold_font()".into(),
515 author: "Robert Lillack".into(),
516 time_seconds: 1_716_200_000,
517 time_offset_minutes: 120,
518 upstream: None,
519 base_name: "main".into(),
520 base_id: Some("d4e5f60718293a4b5c6d7e8f90123456789ab2c3".into()),
521 },
522 vec![],
523 );
524
525 be
526 }
527}
528
529impl RepoBackend for FixtureBackend {
530 fn path(&self) -> &str {
531 &self.path
532 }
533
534 fn commits(&self) -> &[CommitInfo] {
535 &self.commits
536 }
537
538 fn changed_files(&self, index: usize) -> Vec<FileChange> {
539 self.files
540 .get(&index)
541 .map(|entries| entries.iter().map(|e| e.change.clone()).collect())
542 .unwrap_or_default()
543 }
544
545 fn commit_diff(&self, index: usize) -> Diff {
546 let mut lines = Vec::new();
547 if let Some(entries) = self.files.get(&index) {
548 for entry in entries {
549 lines.extend(entry.diff.lines.iter().cloned());
550 }
551 }
552 Diff { lines }
553 }
554
555 fn file_diff(&self, index: usize, path: &str) -> Diff {
556 self.files
557 .get(&index)
558 .and_then(|entries| entries.iter().find(|e| e.change.path == path))
559 .map(|e| e.diff.clone())
560 .unwrap_or_default()
561 }
562
563 fn branches(&self) -> Vec<BranchInfo> {
564 self.branches.iter().map(|(info, _)| info.clone()).collect()
565 }
566
567 fn branch_files(&self, branch: &BranchInfo) -> Vec<FileChange> {
568 self.branch_entries(branch)
569 .map(|entries| entries.iter().map(|e| e.change.clone()).collect())
570 .unwrap_or_default()
571 }
572
573 fn branch_diff(&self, branch: &BranchInfo) -> Diff {
574 let mut lines = Vec::new();
575 if let Some(entries) = self.branch_entries(branch) {
576 for entry in entries {
577 lines.extend(entry.diff.lines.iter().cloned());
578 }
579 }
580 Diff { lines }
581 }
582
583 fn branch_file_diff(&self, branch: &BranchInfo, path: &str) -> Diff {
584 self.branch_entries(branch)
585 .and_then(|entries| entries.iter().find(|e| e.change.path == path))
586 .map(|e| e.diff.clone())
587 .unwrap_or_default()
588 }
589
590 fn working_status(&self, amend: bool) -> WorkingStatus {
591 let mut status = WorkingStatus::default();
592 for entry in self.working.borrow().iter() {
593 if entry.staged {
594 status.staged.push(entry.change.clone());
595 } else {
596 status.unstaged.push(entry.change.clone());
597 }
598 }
599 if amend {
602 let removed = self.amend_removed.borrow();
603 for fe in self.head_files() {
604 if removed.contains(&fe.change.path) {
605 status.unstaged.push(fe.change.clone());
606 } else {
607 status.staged.push(fe.change.clone());
608 }
609 }
610 }
611 status
612 }
613
614 fn working_diff(&self, path: &str, _staged: bool, amend: bool) -> Diff {
615 if let Some(diff) = self
618 .working
619 .borrow()
620 .iter()
621 .find(|e| e.change.path == path)
622 .map(|e| e.diff.clone())
623 {
624 return diff;
625 }
626 if amend
627 && let Some(diff) = self
628 .head_files()
629 .iter()
630 .find(|fe| fe.change.path == path)
631 .map(|fe| fe.diff.clone())
632 {
633 return diff;
634 }
635 Diff::default()
636 }
637
638 fn working_file_blobs(&self, path: &str, _staged: bool, _amend: bool) -> BlobPair {
639 self.working
642 .borrow()
643 .iter()
644 .find(|e| e.change.path == path)
645 .map(|e| e.blobs.clone())
646 .unwrap_or_default()
647 }
648
649 fn stage(&self, path: &str) -> Result<(), String> {
650 let mut found = false;
651 for entry in self.working.borrow_mut().iter_mut() {
652 if entry.change.path == path {
653 entry.staged = true;
654 found = true;
655 }
656 }
657 if !found {
659 self.amend_removed.borrow_mut().remove(path);
660 }
661 Ok(())
662 }
663
664 fn unstage(&self, path: &str, amend: bool) -> Result<(), String> {
665 let mut found = false;
666 for entry in self.working.borrow_mut().iter_mut() {
667 if entry.change.path == path {
668 entry.staged = false;
669 found = true;
670 }
671 }
672 if !found && amend {
674 self.amend_removed.borrow_mut().insert(path.to_string());
675 }
676 Ok(())
677 }
678
679 fn revert(&self, path: &str) -> Result<(), String> {
680 self.working.borrow_mut().retain(|e| {
684 !(e.change.path == path && !e.staged && e.change.status != ChangeStatus::Untracked)
685 });
686 Ok(())
687 }
688
689 fn delete_untracked(&self, path: &str) -> Result<(), String> {
690 self.working.borrow_mut().retain(|e| {
693 !(e.change.path == path && !e.staged && e.change.status == ChangeStatus::Untracked)
694 });
695 Ok(())
696 }
697
698 fn apply_to_index(&self, _patch: &str) -> Result<(), String> {
699 Ok(())
705 }
706
707 fn commit(&self, message: &str, amend: bool) -> Result<(), String> {
708 if message.trim().is_empty() {
709 return Err("Please enter a commit message.".into());
710 }
711 self.working.borrow_mut().retain(|e| !e.staged);
714 self.amend_removed.borrow_mut().clear();
715 *self.last_commit.borrow_mut() = Some((message.to_string(), amend));
716 Ok(())
717 }
718
719 fn head_message(&self) -> Option<String> {
720 self.commits.first().map(|c| c.message.clone())
721 }
722
723 fn signature(&self) -> Option<(String, String)> {
724 self.signature.clone()
725 }
726}
727
728#[allow(clippy::too_many_arguments)]
730pub fn commit(
731 id: &str,
732 summary: &str,
733 author: &str,
734 email: &str,
735 time_seconds: i64,
736 time_offset_minutes: i32,
737 parents: &[&str],
738 refs: &[(&str, RefKind)],
739) -> CommitInfo {
740 CommitInfo {
741 id: id.to_string(),
742 short_id: id.chars().take(8).collect(),
743 summary: summary.to_string(),
744 message: format!("{summary}\n"),
745 author_name: author.to_string(),
746 author_email: email.to_string(),
747 committer_name: author.to_string(),
748 committer_email: email.to_string(),
749 time_seconds,
750 time_offset_minutes,
751 parents: parents.iter().map(|p| p.to_string()).collect(),
752 refs: refs
753 .iter()
754 .map(|(name, kind)| RefLabel {
755 name: name.to_string(),
756 kind: *kind,
757 })
758 .collect(),
759 }
760}
761
762fn file_entry(
763 path: &str,
764 old_path: Option<&str>,
765 status: ChangeStatus,
766 diff_lines: &[(DiffLineKind, &str)],
767) -> FileEntry {
768 FileEntry {
769 change: FileChange {
770 path: path.to_string(),
771 old_path: old_path.map(str::to_string),
772 status,
773 },
774 diff: diff(diff_lines),
775 }
776}
777
778fn diff(lines: &[(DiffLineKind, &str)]) -> Diff {
780 Diff {
781 lines: lines
782 .iter()
783 .map(|(kind, text)| DiffLine::new(*kind, text.to_string()))
784 .collect(),
785 }
786}