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