From d2db3f6b920d2f69999b95e4c3878d1e7da9f583 Mon Sep 17 00:00:00 2001 From: Nicolas Arnaud-Cormos Date: Mon, 21 Sep 2026 21:39:38 +0200 Subject: [PATCH] feat(tui): mark the source rows of a pending commit or fold Pressing `c` or `f` without a selection took the cursor row as the source, then moved the cursor to the destination -- leaving nothing on screen saying what the operation was about to take. The rows a pending command takes are now marked in the selection gutter for as long as its target is being picked: a bold cyan marker on the rows the command names, dim on the rows a source only subsumes, outranking the selection mark. `C` has no named row at all, so it marks the header and the staged files under it. The pane title names the same sources, for the ones scrolled off. The marks live in the `Mode` variant, so they appear and vanish with the mode and cannot leak into the next command; `self.selected` is untouched, so cancelling a fold still leaves the selection intact. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: Ia32223601367b649b62a7f2084a046408ebae9bc --- specs/020-tui.md | 12 +++ src/core/repo.rs | 4 +- src/core/repo_test.rs | 25 +++++ src/tui/app.rs | 233 +++++++++++++++++++++++++++++++++-------- src/tui/app_test.rs | 198 +++++++++++++++++++++++++++++++++- src/tui/status_tree.rs | 21 +++- src/tui/theme.rs | 6 ++ 7 files changed, 447 insertions(+), 52 deletions(-) diff --git a/specs/020-tui.md b/specs/020-tui.md index 9e6847d..c0721e0 100644 --- a/specs/020-tui.md +++ b/specs/020-tui.md @@ -106,6 +106,18 @@ under it. Homogeneity is all the selection enforces: a class an action cannot use (commits for `c`) or more rows than it accepts (several branches for `d`) is still the action's own error. +While a target is being picked (`c`, `C`, `f`), the rows the pending command +takes are marked `▸` in the same gutter, outranking `✓`: what the command +takes matters more than what is selected, and `Space` cannot change the +selection anyway. Rows the command names are marked in full; rows a source +only subsumes are marked dim — the files under a `zz` header, and, for `C`, +the `[local changes]` header and the staged files under it, since the index +it commits is named by no row and the header is drawn even closed. The mark is +what keeps the source visible once the cursor moves on to the destination; +the pane title names the same sources (`Commit → [dest]`, +`Fold into...`, a count when they do not fit), for the ones +scrolled off. Both go when the mode does. + ## Actions Every action runs the regular loom command on a worker thread while the TUI diff --git a/src/core/repo.rs b/src/core/repo.rs index 6e1fbb8..2ee315f 100644 --- a/src/core/repo.rs +++ b/src/core/repo.rs @@ -1114,7 +1114,7 @@ fn get_working_changes_opts(repo: &Repository, recurse_untracked: bool) -> Resul '!' } else if status.is_index_new() { 'A' - } else if status.is_index_modified() { + } else if status.is_index_modified() || status.is_index_typechange() { 'M' } else if status.is_index_deleted() { 'D' @@ -1130,7 +1130,7 @@ fn get_working_changes_opts(repo: &Repository, recurse_untracked: bool) -> Resul '?' } else if status.is_conflicted() { '!' - } else if status.is_wt_modified() { + } else if status.is_wt_modified() || status.is_wt_typechange() { 'M' } else if status.is_wt_deleted() { 'D' diff --git a/src/core/repo_test.rs b/src/core/repo_test.rs index ed23a78..faecc53 100644 --- a/src/core/repo_test.rs +++ b/src/core/repo_test.rs @@ -289,6 +289,31 @@ fn working_tree_changes_detected() { assert_eq!(untracked.worktree, '?'); } +/// Git reports a file swapped for a symlink as a typechange, not a +/// modification: unless both bits map to 'M' the change reads as unchanged. +#[cfg(unix)] +#[test] +fn a_typechange_reports_as_modified() { + let test_repo = TestRepo::new_with_remote(); + test_repo.commit("base", "tracked.txt"); + + let path = test_repo.workdir().join("tracked.txt"); + std::fs::remove_file(&path).unwrap(); + std::os::unix::fs::symlink("elsewhere.txt", &path).unwrap(); + + let changes = get_working_changes(&test_repo.repo).unwrap(); + let change = changes.iter().find(|c| c.path == "tracked.txt").unwrap(); + assert_eq!(change.index, ' '); + assert_eq!(change.worktree, 'M'); + + test_repo.stage_files(&["tracked.txt"]); + + let changes = get_working_changes(&test_repo.repo).unwrap(); + let change = changes.iter().find(|c| c.path == "tracked.txt").unwrap(); + assert_eq!(change.index, 'M'); + assert_eq!(change.worktree, ' '); +} + #[test] fn recurse_untracked_subdirs() { let test_repo = TestRepo::new_with_remote(); diff --git a/src/tui/app.rs b/src/tui/app.rs index f902bf5..04050df 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -27,7 +27,7 @@ use ratatui::{ use crate::core::graph::{self, Section}; use crate::core::hunk_select::HunkArgs; -use crate::core::repo::{self, BranchInfo, CommitInfo, RemoteStatus, RepoInfo}; +use crate::core::repo::{self, BranchInfo, CommitInfo, FileChange, RemoteStatus, RepoInfo}; use crate::core::shortid::IdAllocator; use crate::core::staging; use crate::core::transaction; @@ -36,7 +36,8 @@ use crate::git; use crate::tui::hunk_selector::{FileEntry, run_hunk_selector_nested}; use crate::tui::shell::{KeyResult, PaneId, Shell, ShellApp, ShellConfig, Tick}; use crate::tui::status_tree::{ - self, LOCAL_CHANGES_KEY, PENDING_COMMIT_OID, Row, RowKind, SelectionClass, branch_key, + self, LOCAL_CHANGES_KEY, PENDING_COMMIT_OID, Row, RowKind, RowMark, SelectionClass, branch_key, + working_file_key, }; use crate::tui::theme::TuiTheme; use crate::tui::widgets::common::{colorize_diff, pane_block}; @@ -104,7 +105,7 @@ impl Snapshot { self.info .working_changes .iter() - .filter(|change| matches!(change.index, 'A' | 'M' | 'D' | 'R')) + .filter(|c| is_staged(c)) .count() } } @@ -212,11 +213,13 @@ enum Mode { Normal, FoldTarget { sources: Vec, + source_rows: Sources, }, /// `↑`/`↓` move the placeholder commit through `dests`; the tree is /// rebuilt with it at `dests[index]`. CommitTarget { source: CommitSource, + source_rows: Sources, dests: Vec, index: usize, /// Key of the row `c` was pressed on, to go back to on cancel. @@ -236,6 +239,26 @@ enum Mode { }, } +/// The rows a pending fold or commit takes its content from, marked in the +/// gutter for as long as its target is being picked. Row keys rather than +/// command arguments: keys survive the rebuilds `↑`/`↓` trigger, and a row +/// subsumed by `zz` or by the index carries no argument of its own. +#[derive(Default)] +struct Sources { + /// Rows the command names. + named: HashSet, + /// Rows a named source subsumes: the files under a `zz` header, or the + /// staged files `C` commits through the index. + covered: HashSet, +} + +/// Whether the index holds something of `change` for a `C` commit to take. +/// The one spelling of it: the placeholder's file count and the rows marked +/// as covered must not be able to disagree. +fn is_staged(change: &FileChange) -> bool { + matches!(change.index, 'A' | 'M' | 'D' | 'R') +} + /// Where a pick stands after one poll. enum PickTick { Nothing, @@ -598,16 +621,43 @@ impl<'a> App<'a> { // -- running an action ------------------------------------------------------ + /// The short ID the tree shows for `target`, or `target` itself when no + /// row carries one. + fn sid_of(&self, target: &str) -> String { + self.rows + .iter() + .find(|r| r.target.as_deref() == Some(target) && !r.sid.is_empty()) + .map(|r| r.sid.clone()) + .unwrap_or_else(|| target.to_string()) + } + + /// `title` built from the sources' short IDs, or from a count when that + /// would not fit the pane: the title is what still names the sources + /// once they scroll off the tree. + fn title_sources( + &self, + targets: &[String], + width: u16, + title: impl Fn(&str) -> String, + ) -> String { + let joined = targets + .iter() + .map(|t| self.sid_of(t)) + .collect::>() + .join(" "); + let full = title(&joined); + // Two columns for the corners the title sits between. + if full.chars().count() + 2 <= width as usize { + full + } else { + title(&format!("{} item(s)", targets.len())) + } + } + /// The CLI line equivalent to `action`, with the short IDs the tree /// shows, for the log. fn command_line(&self, action: &Action) -> String { - let sid = |target: &str| -> String { - self.rows - .iter() - .find(|r| r.target.as_deref() == Some(target) && !r.sid.is_empty()) - .map(|r| r.sid.clone()) - .unwrap_or_else(|| target.to_string()) - }; + let sid = |target: &str| self.sid_of(target); let mut words = vec!["loom".to_string()]; match action { Action::Commit { source, dest } => { @@ -1089,31 +1139,96 @@ impl<'a> App<'a> { // -- actions ---------------------------------------------------------------- - /// Targets of the selected rows, in tree order; falls back to the cursor row. - fn selection_targets(&self) -> Vec { - if self.selected.is_empty() { - return self - .current_row() - .and_then(|r| r.target.clone()) - .into_iter() - .collect(); + /// Targets of the selected rows, in tree order, and the rows they mark; + /// falls back to the cursor row. + fn selection_sources(&self) -> (Vec, Sources) { + let rows: Vec<&Row> = if self.selected.is_empty() { + self.current_row().into_iter().collect() + } else { + self.rows + .iter() + .filter(|r| self.selected.contains(&r.key)) + .collect() + }; + let targets = rows.iter().filter_map(|r| r.target.clone()).collect(); + (targets, self.mark_sources(&rows)) + } + + /// The gutter marks for `rows` as the sources of a pending command: the + /// rows themselves, plus the working files a `zz` header among them + /// subsumes. + fn mark_sources(&self, rows: &[&Row]) -> Sources { + let covers_all = rows + .iter() + .any(|r| matches!(r.kind, RowKind::LocalChanges { .. })); + Sources { + named: rows.iter().map(|r| r.key.clone()).collect(), + covered: if covers_all { + self.working_file_keys(|_| true) + } else { + HashSet::new() + }, } - self.rows + } + + /// Keys of the working-file rows matching `keep`, read off the snapshot + /// rather than the drawn rows: `[local changes]` may be closed, and the + /// files it hides are covered all the same. + fn working_file_keys(&self, keep: impl Fn(&FileChange) -> bool) -> HashSet { + self.snapshot + .info + .working_changes .iter() - .filter(|r| self.selected.contains(&r.key)) - .filter_map(|r| r.target.clone()) + .filter(|c| keep(c)) + .map(|c| working_file_key(&c.path)) .collect() } + /// The gutter marks for a `C` commit: the index is the source, so the + /// staged files are all there is to point at — no row names it. + fn index_sources(&self) -> Sources { + let mut covered = self.working_file_keys(is_staged); + // The one row that is drawn whether `[local changes]` is open or + // closed, so a `C` placement is never left with no mark at all. + covered.insert(LOCAL_CHANGES_KEY.to_string()); + Sources { + named: HashSet::new(), + covered, + } + } + + /// The gutter mark for `row`. A pending command's sources outrank the + /// selection, which `Space` cannot change while a target is being picked. + fn row_mark(&self, row: &Row) -> RowMark { + let source_rows = match &self.mode { + Mode::CommitTarget { source_rows, .. } | Mode::FoldTarget { source_rows, .. } => { + Some(source_rows) + } + _ => None, + }; + if let Some(sources) = source_rows { + if sources.named.contains(&row.key) { + return RowMark::Source; + } + if sources.covered.contains(&row.key) { + return RowMark::Covered; + } + } + if self.selected.contains(&row.key) { + return RowMark::Selected; + } + RowMark::None + } + /// `c`: the selected working files or `[local changes]` header (else the /// cursor's) are the commit; the index plays no part. The tree is then /// redrawn with the commit at its destination, and nothing runs until /// that is confirmed. fn action_commit_start(&mut self) { - let Some((files, origin)) = self.commit_sources() else { + let Some((files, source_rows, origin)) = self.commit_sources() else { return; }; - self.enter_commit_target(CommitSource::Files(files), origin); + self.enter_commit_target(CommitSource::Files(files), source_rows, origin); } /// `C`: every local change, whatever the cursor or selection — a picker @@ -1163,10 +1278,11 @@ impl<'a> App<'a> { }); } - /// The working files `c` stands for and the key of the row it came from. - /// `None` when there is nothing to commit, after a notice saying which of - /// the reasons it was — or silently, when there is no row to stand on. - fn commit_sources(&mut self) -> Option<(Vec, String)> { + /// The working files `c` stands for, their gutter marks, and the key of + /// the row it came from. `None` when there is nothing to commit, after a + /// notice saying which of the reasons it was — or silently, when there is + /// no row to stand on. + fn commit_sources(&mut self) -> Option<(Vec, Sources, String)> { let rows: Vec<&Row> = if self.selected.is_empty() { self.current_row().into_iter().collect() } else { @@ -1205,12 +1321,13 @@ impl<'a> App<'a> { self.notice = Some("commit: this row has nothing to commit".to_string()); return None; }; + let source_rows = self.mark_sources(&rows); let origin = self.current_row().map(|r| r.key.clone())?; - Some((files, origin)) + Some((files, source_rows, origin)) } /// Redraw the tree with the placeholder commit and wait for a destination. - fn enter_commit_target(&mut self, source: CommitSource, origin: String) { + fn enter_commit_target(&mut self, source: CommitSource, source_rows: Sources, origin: String) { // The destinations are read off the drawn tree, so their order is the // order `↑`/`↓` walk them in. let mut dests = vec![CommitDest::Integration]; @@ -1220,6 +1337,7 @@ impl<'a> App<'a> { })); self.mode = Mode::CommitTarget { source, + source_rows, dests, index: 0, origin, @@ -1317,7 +1435,8 @@ impl<'a> App<'a> { match staged { Ok(true) => { if self.reload() { - self.enter_commit_target(CommitSource::Index, origin); + let source_rows = self.index_sources(); + self.enter_commit_target(CommitSource::Index, source_rows, origin); } } // Every way out of a pick leaves a line: the entry the press @@ -1403,6 +1522,7 @@ impl<'a> App<'a> { dests, index, origin, + .. } = std::mem::replace(&mut self.mode, Mode::Normal) else { return None; @@ -1428,12 +1548,15 @@ impl<'a> App<'a> { /// `f`: remember the sources, then let the user pick the target in the tree. fn action_fold_start(&mut self) { - let sources = self.selection_targets(); + let (sources, source_rows) = self.selection_sources(); if sources.is_empty() { self.notice = Some("fold: select source rows first".to_string()); return; } - self.mode = Mode::FoldTarget { sources }; + self.mode = Mode::FoldTarget { + sources, + source_rows, + }; } fn confirm_fold_target(&mut self) -> Option { @@ -1442,11 +1565,18 @@ impl<'a> App<'a> { self.notice = Some("fold: this row cannot be a target".to_string()); return None; }; - let Mode::FoldTarget { sources } = std::mem::replace(&mut self.mode, Mode::Normal) else { + let Mode::FoldTarget { + sources, + source_rows, + } = std::mem::replace(&mut self.mode, Mode::Normal) + else { return None; }; if sources.contains(&target) { - self.mode = Mode::FoldTarget { sources }; + self.mode = Mode::FoldTarget { + sources, + source_rows, + }; self.notice = Some("fold: target is one of the sources".to_string()); return None; } @@ -1665,7 +1795,7 @@ impl<'a> App<'a> { row, self.theme, &self.snapshot.cwd_prefix, - self.selected.contains(&row.key), + self.row_mark(row), i == cursor, editing .as_ref() @@ -1677,15 +1807,24 @@ impl<'a> App<'a> { let title = match &self.mode { Mode::Normal => " Status ".to_string(), - Mode::FoldTarget { sources } => { - format!(" Fold {} item(s) into... ", sources.len()) - } - Mode::CommitTarget { dests, index, .. } => { + Mode::FoldTarget { sources, .. } => self.title_sources(sources, area.width, |what| { + format!(" Fold {} into... ", what) + }), + Mode::CommitTarget { + source, + dests, + index, + .. + } => { let dest = match &dests[*index] { CommitDest::Integration => &self.snapshot.info.branch_name, CommitDest::Branch(name) => name, }; - format!(" Commit to [{}] ", dest) + let title = |what: &str| format!(" Commit {} → [{}] ", what, dest); + match source { + CommitSource::Files(files) => self.title_sources(files, area.width, title), + CommitSource::Index => title("the index"), + } } Mode::RenameBranch { .. } => " Rename branch ".to_string(), Mode::NewBranch { .. } => " New branch ".to_string(), @@ -2046,12 +2185,12 @@ fn nearest_focusable(rows: &[Row], index: usize) -> Option { // ── Row rendering ──────────────────────────────────────────────────────── /// Render one tree row as a styled line; `editing` replaces the branch name -/// with the field being typed. The first span is the multi-select gutter. +/// with the field being typed. The first span is the selection/source gutter. fn row_line( row: &Row, theme: &TuiTheme, cwd_prefix: &str, - selected: bool, + mark: RowMark, is_cursor: bool, editing: Option<&TextField>, ) -> Line<'static> { @@ -2061,10 +2200,12 @@ fn row_line( } else { theme.dim }; - let mut spans: Vec> = vec![if selected { - Span::styled("✓ ", theme.selection) - } else { - Span::raw(" ") + let mut spans: Vec> = vec![match mark { + RowMark::Selected => Span::styled("✓ ", theme.selection), + RowMark::Source => Span::styled("▸ ", theme.source), + // Covered, not named: the same glyph, without the source's weight. + RowMark::Covered => Span::styled("▸ ", dim), + RowMark::None => Span::raw(" "), }]; let display = |path: &str| crate::core::repo::cwd_relative_path(path, cwd_prefix); diff --git a/src/tui/app_test.rs b/src/tui/app_test.rs index 169898a..5ce5e31 100644 --- a/src/tui/app_test.rs +++ b/src/tui/app_test.rs @@ -107,6 +107,33 @@ fn press(app: &mut App, code: KeyCode) -> KeyResult { app.handle_key(PaneId::Left, code, KeyModifiers::NONE) } +/// Draw the whole shell and read the screen back as text. +fn rendered_lines(shell: &mut Shell) -> Vec { + rendered_lines_at(shell, 100) +} + +fn rendered_lines_at(shell: &mut Shell, width: u16) -> Vec { + let backend = ratatui::backend::TestBackend::new(width, 30); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + terminal.draw(|f| shell.render(f)).unwrap(); + let buffer = terminal.backend().buffer(); + (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect() +} + +/// The tree line naming `needle`, gutter included. +fn tree_line<'a>(lines: &'a [String], needle: &str) -> &'a str { + lines + .iter() + .find(|l| l.contains(needle)) + .unwrap_or_else(|| panic!("no row for {needle}: {lines:#?}")) +} + /// A worker that blocks until `release` is dropped, then ends as cancelled /// (so finishing it touches no repository). fn blocked_worker(app: &mut App) -> std::sync::mpsc::Sender<()> { @@ -738,7 +765,12 @@ fn a_staged_commit_counts_the_files_the_index_holds() { let theme = make_theme(); let mut app = make_app(make_snapshot(), &theme); - app.enter_commit_target(CommitSource::Index, LOCAL_CHANGES_KEY.to_string()); + let source_rows = app.index_sources(); + app.enter_commit_target( + CommitSource::Index, + source_rows, + LOCAL_CHANGES_KEY.to_string(), + ); let mut shell = Shell::new(app); let backend = ratatui::backend::TestBackend::new(100, 30); @@ -868,7 +900,12 @@ fn a_staged_commit_previews_the_index() { let theme = make_theme(); let mut app = make_app(snapshot, &theme); - app.enter_commit_target(CommitSource::Index, LOCAL_CHANGES_KEY.to_string()); + let source_rows = app.index_sources(); + app.enter_commit_target( + CommitSource::Index, + source_rows, + LOCAL_CHANGES_KEY.to_string(), + ); app.ensure_diff_cached(); let text: String = app.diff_cache[&pending_commit_key()] @@ -2109,10 +2146,165 @@ fn commit_draws_a_placeholder_row_at_its_destination() { .expect("no placeholder commit row"); assert!(lines[at - 1].contains("[feature-a]"), "{lines:#?}"); assert!(lines[at + 1].contains("Add parser"), "{lines:#?}"); - assert!(lines.iter().any(|l| l.contains(" Commit to [feature-a] "))); + assert!( + lines + .iter() + .any(|l| l.contains(" Commit zz → [feature-a] ")) + ); assert!(lines.iter().any(|l| l.contains("Enter to commit"))); } +#[test] +fn commit_marks_the_header_and_the_files_it_covers() { + let theme = make_theme(); + let mut app = make_app(make_snapshot(), &theme); + move_cursor_to(&mut app, LOCAL_CHANGES_KEY); + press(&mut app, KeyCode::Char('c')); + + let mut shell = Shell::new(app); + let lines = rendered_lines(&mut shell); + assert!( + tree_line(&lines, "[local changes]").contains("▸ "), + "{lines:#?}" + ); + assert!(tree_line(&lines, "a.rs").contains("▸ "), "{lines:#?}"); + assert!(tree_line(&lines, "b.rs").contains("▸ "), "{lines:#?}"); + assert!( + !tree_line(&lines, "[feature-a]").contains("▸ "), + "{lines:#?}" + ); +} + +#[test] +fn commit_marks_the_cursor_row_when_nothing_is_selected() { + let theme = make_theme(); + let mut app = make_app(make_snapshot(), &theme); + move_cursor_to(&mut app, "wf:a.rs"); + press(&mut app, KeyCode::Char('c')); + + let mut shell = Shell::new(app); + let lines = rendered_lines(&mut shell); + assert!(tree_line(&lines, "a.rs").contains("▸ "), "{lines:#?}"); + // The sibling is neither named nor covered: `c` took one file. + assert!(!tree_line(&lines, "b.rs").contains("▸ "), "{lines:#?}"); + assert!( + lines + .iter() + .any(|l| l.contains(" Commit aa1 → [integration] ")), + "{lines:#?}" + ); +} + +#[test] +fn cancelling_a_commit_takes_the_source_marks_with_it() { + let theme = make_theme(); + let mut app = make_app(make_snapshot(), &theme); + move_cursor_to(&mut app, "wf:a.rs"); + press(&mut app, KeyCode::Char('c')); + app.handle_escape(); + + let mut shell = Shell::new(app); + let lines = rendered_lines(&mut shell); + assert!(!tree_line(&lines, "a.rs").contains("▸ "), "{lines:#?}"); + assert!(lines.iter().any(|l| l.contains(" Status ")), "{lines:#?}"); +} + +#[test] +fn fold_marks_its_sources_while_the_target_is_picked() { + let theme = make_theme(); + let mut app = make_app(make_snapshot(), &theme); + move_cursor_to(&mut app, "wf:a.rs"); + app.toggle_selection(); + app.action_fold_start(); + move_cursor_to(&mut app, &oid('a').to_string()); + + let mut shell = Shell::new(app); + let lines = rendered_lines(&mut shell); + // The source outranks its own `✓`: the cursor is on the target now. + assert!(tree_line(&lines, "a.rs").contains("▸ "), "{lines:#?}"); + assert!(!tree_line(&lines, "a.rs").contains("✓ "), "{lines:#?}"); + assert!( + lines.iter().any(|l| l.contains(" Fold aa1 into... ")), + "{lines:#?}" + ); +} + +#[test] +fn a_title_too_narrow_for_the_sources_counts_them_instead() { + let theme = make_theme(); + let mut info = make_info(); + info.working_changes = (0..6) + .map(|i| file(&format!("f{i}.rs"), ' ', 'M')) + .collect(); + let mut app = make_app(snapshot_of(info), &theme); + for i in 0..6 { + move_cursor_to(&mut app, &format!("wf:f{i}.rs")); + app.toggle_selection(); + } + press(&mut app, KeyCode::Char('c')); + + let mut shell = Shell::new(app); + // Wide enough for the count, too narrow for the six short IDs. + let lines = rendered_lines_at(&mut shell, 90); + assert!( + lines + .iter() + .any(|l| l.contains(" Commit 6 item(s) → [integration] ")), + "{lines:#?}" + ); +} + +/// The staged files are hidden behind a closed header, so the header itself +/// has to carry the mark: a `C` placement with nothing marked is the case the +/// marks exist for. +#[test] +fn a_staged_commit_marks_the_closed_header() { + let theme = make_theme(); + let mut app = make_app(make_snapshot(), &theme); + app.expanded.remove(LOCAL_CHANGES_KEY); + app.rebuild_rows(LOCAL_CHANGES_KEY); + let source_rows = app.index_sources(); + app.enter_commit_target( + CommitSource::Index, + source_rows, + LOCAL_CHANGES_KEY.to_string(), + ); + + let mut shell = Shell::new(app); + let lines = rendered_lines(&mut shell); + assert!( + tree_line(&lines, "[local changes]").contains("▸ "), + "{lines:#?}" + ); +} + +/// `C` commits the index, which no row names — only the staged files it +/// carries can be pointed at. +#[test] +fn a_staged_commit_marks_the_files_the_index_holds() { + let theme = make_theme(); + let mut app = make_app(make_snapshot(), &theme); + let source_rows = app.index_sources(); + app.enter_commit_target( + CommitSource::Index, + source_rows, + LOCAL_CHANGES_KEY.to_string(), + ); + + let mut shell = Shell::new(app); + let lines = rendered_lines(&mut shell); + assert!(tree_line(&lines, "a.rs").contains("▸ "), "{lines:#?}"); + assert!(!tree_line(&lines, "b.rs").contains("▸ "), "{lines:#?}"); + assert!( + tree_line(&lines, "[local changes]").contains("▸ "), + "{lines:#?}" + ); + assert!( + lines.iter().any(|l| l.contains(" Commit the index → ")), + "{lines:#?}" + ); +} + #[test] fn render_smoke_test_on_every_focusable_row() { let theme = make_theme(); diff --git a/src/tui/status_tree.rs b/src/tui/status_tree.rs index 420dc1b..c160f88 100644 --- a/src/tui/status_tree.rs +++ b/src/tui/status_tree.rs @@ -23,6 +23,25 @@ pub(crate) fn branch_key(name: &str) -> String { format!("br:{}", name) } +/// [`Row::key`] of the row naming the working file at `path`. +pub(crate) fn working_file_key(path: &str) -> String { + format!("wf:{}", path) +} + +/// What the gutter marker on a row says. `Source` and `Covered` occur only +/// while a target is being picked, and outrank `Selected`: what the pending +/// command takes matters more than what is selected (Spec 020). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum RowMark { + None, + Selected, + /// A row the pending command names. + Source, + /// A row a named source subsumes without naming it: a file under a `zz` + /// header, or a staged file the index carries in. + Covered, +} + /// The kind of thing a selection holds. A selection never mixes classes: no /// loom command takes a heterogeneous target list (Spec 020). #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -192,7 +211,7 @@ pub(crate) fn build_rows( }, sid: ids.get_file(&change.path).to_string(), target: Some(ids.get_file(&change.path).to_string()), - key: format!("wf:{}", change.path), + key: working_file_key(&change.path), focusable: true, expandable: false, expanded: false, diff --git a/src/tui/theme.rs b/src/tui/theme.rs index 0887123..ba44de9 100644 --- a/src/tui/theme.rs +++ b/src/tui/theme.rs @@ -57,6 +57,9 @@ pub struct TuiTheme { pub remote_gone: Style, /// Marker for multi-selected rows in the status tree. pub selection: Style, + /// Marker for the source rows of a fold or commit being placed; distinct + /// from [`TuiTheme::selection`] so it does not read as a selection. + pub source: Style, /// Rotating colors for commit dots on feature branches. pub branch_dots: Vec