diff --git a/README.md b/README.md index 03d04515..e77a5f80 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ on a tab tells you exactly where to look without opening anything. | **Agents that drive nebula back** | Tell a Claude SESSION *"do this in a worktree"* and it runs `nebula worktree`, then restarts itself resumed inside the new checkout. Say *"show me the file"* and `nebula open` puts it in front of you in a tabbed modal. Say *"start a new nebula session that…"* and `nebula spawn` has a second agent working beside it before you look. | | **Every open pull request, in place** | nebula asks `gh` what's open on the repo. A band whose branch has a pull request names it on its rule — `↗ #57 Move the token store to sqlite ready` — in red once GitHub says it no longer merges or a check failed, purple once it's merged; the header counts `3 prs · 5 issues`. `v` lists them and reads the one under the cursor — description, checks, the whole conversation — `Ctrl+g` for its diff, `Ctrl+c` to comment without leaving the keyboard, `Enter` for a PR SESSION in the pull request's own checkout, `Shift+V` on a card sends its pull request to the browser. | | **Every open issue, one key from an agent** | `i` lists the project's open GitHub issues, newest first, filtered as you type, and reads the one under the cursor — description, labels, comments. `Enter` opens the QUICK PROMPT for it, `Shift+Tab` launches one of your AGENT PRESETS on it, `Ctrl+e` edits its title and description in place; the issue's URL travels with the session as context on every spawn, so the harness knows what it is fixing. `Shift+I` on the card opens the issue it came from. | -| **Diff, find, grep, browse** | `g` opens the DIFF VIEWER with REVIEWED MARKS, `f` the FILE FINDER, `F` a `git grep`, `b` the TREE BROWSER — all scoped to the card's WORKTREE, all one key from anywhere. Markdown previews are rendered pages, not raw `#` and `*`. | +| **Diff, find, grep, browse** | `g` opens the DIFF VIEWER, a VS Code style source control view (changes with REVIEWED MARKS over a commit graph of local and remote branches, side-by-side diffs), `f` the FILE FINDER, `F` a `git grep`, `b` the TREE BROWSER — all scoped to the card's WORKTREE, all one key from anywhere. Markdown previews are rendered pages, not raw `#` and `*`. | | **`/` finds anything, anywhere** | The PALETTE spans every PROJECT on the machine, each session listed with its project in front of it. Before you type it is that overview, sorted by attention: NEEDS FEEDBACK first, then RUNNING, then UNSEEN — so `/` `Enter` is the fastest way back to whatever needs you, and `.` / `,` cycle that same attention order with no modal at all, one session per press, every project included. Open pull requests are rows too. | | **It follows you to other machines** | `nebula ssh ` opens nebula there, installing it if missing. `nebula tunnel ` puts that machine's TUI in a browser tab over a single ssh tunnel. Your settings and agent presets go along, and `nebula config export` / `import` back them up. | diff --git a/crates/nebula-tui/src/app.rs b/crates/nebula-tui/src/app.rs index 6c8e5be3..b0fecabd 100644 --- a/crates/nebula-tui/src/app.rs +++ b/crates/nebula-tui/src/app.rs @@ -175,6 +175,9 @@ pub enum HitTarget { /// Default outer width of the diff modal's file-list panel. pub const DEFAULT_DIFF_FILES_W: u16 = 34; +/// The DIFF VIEWER's sidebar width until it is dragged: one part in this +/// many of the modal, room for a commit's subject beside the graph. +pub const DIFF_SIDEBAR_SHARE: u16 = 3; /// The diff modal's file list can't be dragged narrower than this. pub const MIN_DIFF_FILES_W: u16 = 16; /// How long the settings overlay remembers its tab / row / strip-vs-list @@ -1081,6 +1084,26 @@ pub struct DiffView { /// the tree's — `selected` and `matches` stay current underneath, so /// toggling back lands on a list that is already right. pub tree: Option, + /// The GRAPH section under the CHANGES (`git_log`); `None` in a view + /// that has no checkout history of its own (a pull request's). + pub log: Option, + /// Where the sidebar's cursor is: on a section header, among the + /// changed files (`selected` / the tree's), or in the GRAPH. + pub place: Place, + /// The sections unfolded under their headers. + pub changes_open: bool, + pub graph_open: bool, + /// The reader has moved the cursor: what lands from now on leaves it + /// where they put it (`settle`). + pub touched: bool, + /// Diffs side by side (`Ctrl+s` flips it), remembered across opens. + /// The pane falls back to the unified diff when it is too narrow + /// ([`MIN_SPLIT_W`]). + pub split: bool, + /// `diff` laid out side by side, while `split` is on and it has hunks. + pub split_rows: Option>, + /// The last draw showed the diff side by side, written back by it. + pub split_shown: bool, } /// The most diff text a DIFF VIEWER keeps beyond the one on screen. Two @@ -1089,20 +1112,168 @@ pub struct DiffView { /// cheaper than holding it. pub const DIFF_CACHE_BYTES: usize = 2 * 1024 * 1024; pub const DIFF_CACHE_ENTRY_MAX: usize = 512 * 1024; +/// The narrowest diff pane that shows a diff side by side: two halves of +/// numbered lines narrower than this are too cut to read. +pub const MIN_SPLIT_W: u16 = 90; + +/// Where the DIFF VIEWER's cursor is in its SOURCE CONTROL sidebar. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Place { + ChangesHeader, + Changes, + GraphHeader, + Graph, +} impl DiffView { - /// A view up before its file list is: `g` opens this at once and - /// `event_loop::land_view_answer` fills it when `git status` answers. - pub fn opening( - root: PathBuf, - branch: String, - jobs: crate::view_jobs::Jobs, - listing: u64, - ) -> Self { - let mut view = Self::new(root, branch, Vec::new(), true); - view.jobs = Some(jobs); - view.listing = Some(listing); - view + /// What the right pane should be showing (`shown`'s key): the + /// cursor's changed file, a section header's `git status`, or the + /// cursor's GRAPH row. + pub fn selected_key(&self) -> Option { + match self.place { + Place::Changes => self.selected_file().map(|f| f.path.clone()), + Place::Graph => self.log.as_ref()?.selected_key(), + _ => Some(crate::git_log::WORKING.to_string()), + } + } + + /// The CHANGES rows under their header: none while it is folded. + fn changes_len(&self) -> usize { + if self.changes_open { + self.row_count() + } else { + 0 + } + } + + /// How many rows the sidebar has: each section's header and, unfolded, + /// its rows. + pub fn side_len(&self) -> usize { + let graph = match &self.log { + Some(log) if self.graph_open => 1 + log.rows.len(), + Some(_) => 1, + None => 0, + }; + 1 + self.changes_len() + graph + } + + /// The cursor's row in the sidebar. + pub fn side_cursor(&self) -> usize { + match self.place { + Place::ChangesHeader => 0, + Place::Changes => 1 + self.cursor(), + Place::GraphHeader => 1 + self.changes_len(), + Place::Graph => 2 + self.changes_len() + self.log.as_ref().map_or(0, |l| l.selected), + } + } + + /// What sidebar row `index` is: its place, and its row in that list. + pub fn side_row(&self, index: usize) -> Option<(Place, usize)> { + let changes = self.changes_len(); + match index { + 0 => Some((Place::ChangesHeader, 0)), + i if i <= changes => Some((Place::Changes, i - 1)), + i if i >= self.side_len() => None, + i if i == changes + 1 => Some((Place::GraphHeader, 0)), + i => Some((Place::Graph, i - changes - 2)), + } + } + + /// Move the sidebar cursor to row `index` (clamped), across the + /// sections, stepping over the graph's connecting lines the way it + /// moves. True when it moved (the caller reloads the pane). + pub fn side_select(&mut self, index: i64) -> bool { + let before = (self.place, self.side_cursor()); + let last = self.side_len().saturating_sub(1) as i64; + let down = index >= before.1 as i64; + let Some((place, row)) = self.side_row(index.clamp(0, last) as usize) else { + return false; + }; + match place { + Place::Changes => { + self.select(row as i64); + } + Place::Graph => { + if let Some(log) = &mut self.log { + log.select_toward(row, down); + } + } + _ => {} + } + self.place = place; + let moved = (self.place, self.side_cursor()) != before; + self.touched |= moved; + moved + } + + /// Put a cursor the reader has not moved where opening the modal + /// should: on the first changed file, or with none, on HEAD's commit. + /// Nothing moves while the changes are still being read. True when it + /// moved. + pub fn settle(&mut self) -> bool { + if self.touched || self.listing.is_some() { + return false; + } + let before = (self.place, self.side_cursor()); + let changes = self.row_count(); + let graph = self + .log + .as_mut() + .filter(|l| l.rows.iter().any(|r| r.entry.selectable())); + if changes > 0 { + self.place = Place::Changes; + } else if let Some(log) = graph { + log.go_home(); + self.place = Place::Graph; + } else { + self.place = Place::ChangesHeader; + } + (self.place, self.side_cursor()) != before + } + + /// Fold or unfold the section whose header the cursor is on (`open` + /// None flips it). True when that changed anything. + pub fn fold_section(&mut self, open: Option) -> bool { + let section = match self.place { + Place::ChangesHeader => &mut self.changes_open, + Place::GraphHeader => &mut self.graph_open, + _ => return false, + }; + let want = open.unwrap_or(!*section); + let changed = want != *section; + *section = want; + changed + } + + /// `←`/`→` fold and unfold: on a header, a GRAPH row and the tree. + /// Everywhere else (the flat list of changes) they move the filter's + /// caret. + pub fn folds_on_arrows(&self) -> bool { + self.place != Place::Changes || self.tree.is_some() + } + + /// Lay `diff` out side by side again, if `split` wants it. + fn refresh_split(&mut self) { + self.split_rows = if self.split { + crate::git_diff::split_rows(&self.diff).map(Into::into) + } else { + None + }; + } + + /// `Ctrl+s`: side by side, or unified. + pub fn toggle_split(&mut self) { + self.split = !self.split; + self.refresh_split(); + } + + /// The rows the diff pane scrolls over, in the layout the last draw + /// used. + pub fn shown_rows(&self) -> usize { + match &self.split_rows { + Some(rows) if self.split_shown => rows.len(), + _ => self.diff_line_count, + } } /// The cached diff of `path`, if this modal has read it. @@ -1132,6 +1303,7 @@ impl DiffView { pub fn show_diff(&mut self, path: Option<&str>, diff: String, keep_scroll: bool) { self.diff_line_count = diff.lines().count(); self.diff = diff; + self.refresh_split(); self.shown = path.map(str::to_string); if !keep_scroll { self.scroll = 0; @@ -1166,13 +1338,21 @@ impl DiffView { shown: None, cache: Vec::new(), tree: None, + log: None, + place: Place::Changes, + changes_open: true, + graph_open: true, + touched: false, + split: false, + split_rows: None, + split_shown: false, }; view.apply_filter(); view } pub fn max_scroll(&self) -> u16 { - max_scroll(self.diff_line_count, self.view_height) + max_scroll(self.shown_rows(), self.view_height) } /// Screen x of the files/diff boundary — the column where the diff panel @@ -1221,8 +1401,12 @@ impl DiffView { } /// The file behind the current selection, if any row is visible — and, - /// in the tree, if that row is a file's. + /// in the tree, if that row is a file's. None while the cursor is + /// outside the CHANGES. pub fn selected_file(&self) -> Option<&DiffFile> { + if self.place != Place::Changes { + return None; + } match &self.tree { Some(tree) => self.files.get(tree.selected_file()?), None => self.files.get(self.matches.get(self.selected)?.file), @@ -1261,7 +1445,7 @@ impl DiffView { /// First visible row of the file list's stateless follow-window for a /// list of `height` rows. pub fn window_start(&self, height: usize) -> usize { - window_start(self.cursor(), height) + window_start(self.side_cursor(), height) } /// Whether the cursor is still where opening the modal put it: the top @@ -2832,13 +3016,18 @@ pub struct UiState { /// older blobs, which keep it open. #[serde(default)] pub issues_collapsed: bool, - /// Diff modal file-list width; absent in older blobs. + /// Diff modal sidebar width, 0 until dragged; absent in older blobs, + /// whose `diff_files_width` sized a bare file list and is left behind. #[serde(default)] - pub diff_files_width: Option, + pub diff_sidebar_width: Option, /// The diff modal's file list is the directory tree (`Ctrl+t`); absent /// in older blobs, which keep the flat list. #[serde(default)] pub diff_tree: bool, + /// The diff modal shows diffs unified rather than side by side + /// (`Ctrl+s` inside it); absent in older blobs, which get side by side. + #[serde(default)] + pub diff_unified: bool, /// Height the LAUNCHER VIEW's pane was dragged to; absent in older /// blobs, and None in ones written before the edge was ever dragged, /// both of which open the pane on its default share. @@ -3504,11 +3693,15 @@ pub struct App { /// File paths detected on the visible screen during the last draw; /// ⌥click opens them in the editor modal. pub term_file_links: Vec, - /// File-list width of the diff modal, remembered across opens. + /// Sidebar width of the diff modal, remembered across opens; 0 until + /// it is dragged, which gives it [`DIFF_SIDEBAR_SHARE`] of the modal. pub diff_files_width: u16, /// The diff modal lists its files as a directory tree (`Ctrl+t` inside /// it), remembered across opens and launches like the width. pub diff_tree: bool, + /// The diff modal shows diffs side by side (`Ctrl+s` inside it), + /// remembered the same way. + pub diff_split: bool, /// Selected tab of the settings modal, remembered across opens. pub settings_tab: usize, /// Cursor row of the settings modal, one per tab, remembered across @@ -3746,10 +3939,6 @@ pub struct App { /// through the main loop instead of holding it. None with no loop /// running (unit tests), where those views read inline. pub view_jobs: Option, - /// A `g` on a checkout the changed-files badge called clean: the ticket - /// of the `git status` checking that, and the checkout (path, branch) - /// to open the DIFF VIEWER on if git disagrees. - pub diff_probe: Option<(u64, PathBuf, String)>, /// The changed files the badge's last `git status` listed, and the /// checkout they are in (`event_loop::keep_changed_files`): what `g` /// opens the DIFF VIEWER on while its own `git status` runs. One @@ -3902,8 +4091,9 @@ impl App { last_pane_edge_click: None, term_links: Vec::new(), term_file_links: Vec::new(), - diff_files_width: DEFAULT_DIFF_FILES_W, + diff_files_width: 0, diff_tree: false, + diff_split: true, settings_tab: 0, settings_selected: vec![0; crate::config::tab_count()], settings_on_tabs: true, @@ -3963,7 +4153,6 @@ impl App { pending_issue_detail: None, issues_tx: None, view_jobs: None, - diff_probe: None, changed_files: None, deleting: std::collections::HashSet::new(), branch_switch: Default::default(), diff --git a/crates/nebula-tui/src/event_loop.rs b/crates/nebula-tui/src/event_loop.rs index 4f16747d..1a8161f5 100644 --- a/crates/nebula-tui/src/event_loop.rs +++ b/crates/nebula-tui/src/event_loop.rs @@ -8,7 +8,7 @@ use crate::app::{ PointerShape, PromptDialog, PromptKind, SessionRow, SettingsView, SubmenuKind, TermSelection, WorktreeRollback, }; -use crate::app::{PrCommentAnswer, PrDiffAnswer}; +use crate::app::{Place, PrCommentAnswer, PrDiffAnswer}; use crate::pull_request::Lookup; use crate::text_input::TextInput; use crate::tree_browser::TreeBrowser; @@ -1916,6 +1916,7 @@ fn open_pr_diff_view(app: &mut App, number: u64, url: &str, title: String, diff: view.prefetched = Some(chunks.into_iter().collect()); view.pr_url = Some(url.to_string()); view.files_width = app.diff_files_width; + view.split = app.diff_split; if app.diff_tree { view.toggle_tree(); } @@ -2185,8 +2186,9 @@ fn ui_state_json(app: &App) -> String { collapsed: app.collapsed, open_prs_collapsed: app.open_prs_collapsed, issues_collapsed: app.issues_collapsed, - diff_files_width: Some(app.diff_files_width), + diff_sidebar_width: Some(app.diff_files_width), diff_tree: app.diff_tree, + diff_unified: !app.diff_split, launcher_pane_h: app.launcher_pane_h, launcher_pane_w: app.launcher_pane_w, launcher_pane_hidden: app.launcher_pane_hidden, @@ -2233,11 +2235,12 @@ fn restore_ui_state(app: &mut App, json: &str) -> bool { app.show_archived = state.show_archived; app.open_prs_collapsed = state.open_prs_collapsed; app.issues_collapsed = state.issues_collapsed; - if let Some(w) = state.diff_files_width { - // The draw re-caps it to the actual modal width. + // The draw re-caps it to the actual modal width; 0 was never dragged. + if let Some(w) = state.diff_sidebar_width.filter(|w| *w > 0) { app.diff_files_width = w.clamp(crate::app::MIN_DIFF_FILES_W, MAX_RESTORED_WIDTH); } app.diff_tree = state.diff_tree; + app.diff_split = !state.diff_unified; // The next draw re-fits it to the body actually on screen // (`launcher::pane_height`); the cap here only keeps a nonsense blob // from carrying a wild number around. @@ -4039,77 +4042,40 @@ fn load_worktree_files( /// the pruned set is written back. Restored marks sink to the bottom, so /// the modal opens on the first unreviewed file. /// -/// A checkout the changed-files badge already knows to be clean is told so -/// on the spot instead of being shown a modal that closes again; the badge -/// can be two seconds behind an agent, so git is still asked, and the -/// modal opens after all if it disagrees (`App::diff_probe`). +/// The CHANGES share the sidebar with the GRAPH (`git_log`), read beside +/// them; a checkout with nothing changed opens on HEAD's commit. fn open_diff_view(app: &mut App) { let Some((path, branch)) = selected_checkout(app) else { return; }; - let Some(jobs) = app.view_jobs.clone() else { - // No loop to land an answer on (unit tests): read inline. - match crate::git_diff::read_listing(&path) { - Ok(listing) => show_diff_listing(app, path, branch, listing), - Err(msg) => app.flash = Some(msg), - } - return; - }; - let ticket = crate::view_jobs::ticket(); - let selected = app.selected_worktree().map(|w| w.id.clone()); - let known_clean = - matches!(&app.git_changes, Some((id, Some(0))) if Some(id) == selected.as_ref()); - if known_clean { - app.flash = Some(format!("no changes in {branch}")); - app.diff_probe = Some((ticket, path.clone(), branch)); - } else { - let mut view = DiffView::opening(path.clone(), branch, jobs.clone(), ticket); - view.files_width = app.diff_files_width; - if app.diff_tree { - view.toggle_tree(); - } - // The badge's last `git status` — two seconds old at most — is the - // list to open on: the files are up on this keypress and the first - // diff is being read while the `git status` below checks them, not - // after it. `fill_view` reconciles the two when that lands. - let polled = app - .changed_files - .as_ref() - .filter(|(id, files)| Some(id) == selected.as_ref() && !files.is_empty()); - if let Some((_, files)) = polled { - view.replace_files(files.clone()); - crate::git_diff::load_selected_diff(&mut view); - } - app.overlay = Some(Overlay::Diff(view)); - } - jobs.run(move || { - Some(crate::view_jobs::Answer::DiffListing { - ticket, - result: crate::git_diff::read_listing(&path), - }) - }); -} - -/// Open the DIFF VIEWER on a listing already in hand — or say there is -/// nothing to show. -fn show_diff_listing( - app: &mut App, - path: std::path::PathBuf, - branch: String, - listing: crate::view_jobs::DiffListing, -) { - if listing.files.is_empty() { - app.flash = Some(format!("no changes in {branch}")); - return; - } let mut view = DiffView::new(path, branch, Vec::new(), true); view.jobs = app.view_jobs.clone(); view.files_width = app.diff_files_width; - crate::git_diff::fill_view(&mut view, listing); - // After the marks: the tree opens on the first unreviewed file too. - if app.diff_tree && view.toggle_tree() { + view.split = app.diff_split; + view.place = Place::ChangesHeader; + if app.diff_tree { + view.toggle_tree(); + } + let selected = app.selected_worktree().map(|w| w.id.clone()); + // The badge's last `git status` — two seconds old at most — is the list + // to open on: the files are up on this keypress and the first diff is + // being read while the `git status` below checks them, not after it. + // `fill_view` reconciles the two when that lands. + let polled = app + .changed_files + .as_ref() + .filter(|(id, files)| Some(id) == selected.as_ref() && !files.is_empty()); + if let Some((_, files)) = polled.filter(|_| view.jobs.is_some()) { + view.replace_files(files.clone()); + view.place = Place::Changes; crate::git_diff::load_selected_diff(&mut view); } + crate::git_log::request_log(&mut view); + // With no loop to land on (unit tests) the listing is read inline. + if let Err(msg) = crate::git_diff::request_listing(&mut view) { + app.flash = Some(msg); + return; + } app.overlay = Some(Overlay::Diff(view)); } @@ -4218,6 +4184,20 @@ fn land_view_answer(app: &mut App, answer: crate::view_jobs::Answer) { crate::git_diff::land_diff(view, id, ticket, &path, diff, prefetch); } } + Answer::Log { ticket, result } => { + if let Some(Overlay::Diff(view)) = &mut app.overlay { + crate::git_log::land_log(view, ticket, result); + } + } + Answer::CommitFiles { + view: id, + sha, + result, + } => { + if let Some(Overlay::Diff(view)) = &mut app.overlay { + crate::git_log::land_files(view, id, &sha, result); + } + } Answer::Preview { ticket, preview } => match &mut app.overlay { Some(Overlay::Tree(view)) => view.land_preview(ticket, *preview), Some(Overlay::FileTabs(view)) => view.land_preview(ticket, *preview), @@ -4269,46 +4249,23 @@ fn land_worktree_files(app: &mut App, ticket: u64, result: Result, S } } -/// `git status` came back for a `g`: fill the DIFF VIEWER that opened ahead -/// of it — or close it, saying why, when there is nothing to show — or, for -/// the checkout that was told "no changes" off the badge, open it after all -/// when git found some and nothing else has taken the screen since. +/// A file list came back for the DIFF VIEWER that asked for it: the +/// checkout's changes (a clean checkout puts its HISTORY up instead), or a +/// commit's files. One git could not read closes the modal with the reason. fn land_diff_listing( app: &mut App, ticket: u64, result: Result, ) { - let probe = match &app.diff_probe { - Some((probed, ..)) if *probed == ticket => app.diff_probe.take(), - _ => None, + let Some(Overlay::Diff(view)) = &mut app.overlay else { + return; }; - if let Some((_, path, branch)) = probe { - if let Ok(listing) = result { - if !listing.files.is_empty() && app.overlay.is_none() && app.vim.is_none() { - app.flash = None; - show_diff_listing(app, path, branch, listing); - } - } + if view.listing != Some(ticket) { return; } - let branch = match &app.overlay { - Some(Overlay::Diff(view)) if view.listing == Some(ticket) => view.branch.clone(), - _ => return, - }; - match result { - Ok(listing) if !listing.files.is_empty() => { - if let Some(Overlay::Diff(view)) = &mut app.overlay { - crate::git_diff::fill_view(view, listing); - } - } - Ok(_) => { - app.overlay = None; - app.flash = Some(format!("no changes in {branch}")); - } - Err(msg) => { - app.overlay = None; - app.flash = Some(msg); - } + if let Err(msg) = crate::git_diff::take_listing(view, result) { + app.overlay = None; + app.flash = Some(msg); } } @@ -5808,10 +5765,11 @@ pub(crate) fn handle_overlay_key(app: &mut App, key: KeyEvent, out: &mut Vec { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); let shift = key.modifiers.contains(KeyModifiers::SHIFT); - // Ctrl+d/u walk the file list half its height, as in vim — - // the flat list and the tree alike; the diff pages on PgUp/PgDn. + // Ctrl+d/u walk the sidebar half its height, as in vim; the + // diff pages on PgUp/PgDn. let half = (view.list_area.height / 2).max(1) as i64; let page = view.view_height.max(1) as i32; + let at = view.side_cursor() as i64; match key.code { // Two-stage escape: an active filter is cleared before the // second Esc closes the modal. @@ -5820,13 +5778,11 @@ pub(crate) fn handle_overlay_key(app: &mut App, key: KeyEvent, out: &mut Vec app.overlay = None, - KeyCode::Char('d') if ctrl => { - activate::diff_file(view, view.cursor() as i64 + half) - } + KeyCode::Char('d') if ctrl => activate::diff_file(view, at + half), // Ctrl+u is the line editor's kill-to-start while something // is typed; only with an empty filter does it move. KeyCode::Char('u') if ctrl && view.filter.is_empty() => { - activate::diff_file(view, view.cursor() as i64 - half) + activate::diff_file(view, at - half) } // Ctrl+r toggles the reviewed ✓ on the selected file — // nebula-side bookkeeping only, no git state is touched. @@ -5849,19 +5805,22 @@ pub(crate) fn handle_overlay_key(app: &mut App, key: KeyEvent, out: &mut Vec { + view.toggle_split(); + app.diff_split = view.split; + } KeyCode::Down if shift => view.scroll_by(1), KeyCode::Up if shift => view.scroll_by(-1), - KeyCode::Down => activate::diff_file(view, view.cursor() as i64 + 1), - KeyCode::Up => activate::diff_file(view, view.cursor() as i64 - 1), - // The tree folds on the TREE BROWSER's keys: →/← open and - // fold a directory (or step in / out to the parent), Enter - // flips the one under the cursor. In the flat list all - // three stay the filter's. - KeyCode::Right if view.tree.is_some() => activate::diff_tree_step(view, true), - KeyCode::Left if view.tree.is_some() => activate::diff_tree_step(view, false), - KeyCode::Enter if view.tree.is_some() => { - activate::diff_row(view, view.cursor() as i64) - } + KeyCode::Down => activate::diff_file(view, at + 1), + KeyCode::Up => activate::diff_file(view, at - 1), + // →/← unfold and fold what the cursor is on (a section, a + // commit, a tree directory) or step in / out to the + // parent, as in the TREE BROWSER; Enter flips it. On the + // flat list's files all three stay the filter's. + KeyCode::Right if view.folds_on_arrows() => activate::diff_fold(view, true), + KeyCode::Left if view.folds_on_arrows() => activate::diff_fold(view, false), + KeyCode::Enter if view.folds_on_arrows() => activate::diff_row(view, at), KeyCode::PageDown => view.scroll_by(page), KeyCode::PageUp => view.scroll_by(-page), KeyCode::Home => view.scroll = 0, @@ -9212,20 +9171,20 @@ fn handle_mouse(app: &mut App, mouse: MouseEvent, out: &mut Vec) app.dirty = true; return; } - // Diff modal: the wheel over the file list walks its cursor a row a - // notch (↑/↓'s own step), anywhere else it scrolls the diff; a click on - // a file-list row selects that file (and folds or unfolds a tree - // directory's), a drag on the files/diff border resizes the file list; - // everything else is swallowed. + // Diff modal: the wheel over the sidebar walks its cursor a row a notch + // (↑/↓'s own step), anywhere else it scrolls the diff; a click on a + // sidebar row selects it (and folds or unfolds a section header, a + // commit or a tree directory), a drag on the sidebar/diff border + // resizes the sidebar; everything else is swallowed. if let Some(Overlay::Diff(view)) = &mut app.overlay { let over_files = view.area.contains(mouse_pos) && mouse.column < view.splitter_x(); match mouse.kind { MouseEventKind::ScrollUp if over_files => { - activate::diff_file(view, view.cursor() as i64 - 1); + activate::diff_file(view, view.side_cursor() as i64 - 1); app.dirty = true; } MouseEventKind::ScrollDown if over_files => { - activate::diff_file(view, view.cursor() as i64 + 1); + activate::diff_file(view, view.side_cursor() as i64 + 1); app.dirty = true; } MouseEventKind::ScrollUp => { @@ -9247,7 +9206,7 @@ fn handle_mouse(app: &mut App, mouse: MouseEvent, out: &mut Vec) let area = view.list_area; let first = view.window_start(area.height as usize); if let Some(index) = - crate::list_hit::row_at(area, first, view.row_count(), mouse_pos) + crate::list_hit::row_at(area, first, view.side_len(), mouse_pos) { activate::diff_row(view, index as i64); app.dirty = true; @@ -14023,7 +13982,7 @@ diff --git a/docs/keys.md b/docs/keys.md let text = buffer_text(&terminal); assert!(text.contains("▾ crates/tui/src"), "{text}"); assert!(text.contains("^t: flat list"), "{text}"); - assert!(text.contains("←/→: fold"), "{text}"); + assert!(text.contains("→/←: unfold/fold"), "{text}"); // Up onto the directory's row: the pane lists what is under it. press(&mut app, KeyCode::Up, KeyModifiers::NONE, &mut out); @@ -14091,9 +14050,10 @@ diff --git a/docs/keys.md b/docs/keys.md ); }; + // Row 0 is the FILES header. press(&mut keyed, KeyCode::Up, KeyModifiers::NONE, &mut out); press(&mut keyed, KeyCode::Enter, KeyModifiers::NONE, &mut out); - click(&mut clicked, 0, &mut out); + click(&mut clicked, 1, &mut out); assert_eq!( diff_tree_rows(&keyed), ["*crates/tui/src", "docs", "keys.md"] @@ -14105,7 +14065,7 @@ diff --git a/docs/keys.md b/docs/keys.md press(&mut keyed, KeyCode::Down, KeyModifiers::NONE, &mut out); press(&mut keyed, KeyCode::Down, KeyModifiers::NONE, &mut out); press(&mut keyed, KeyCode::Enter, KeyModifiers::NONE, &mut out); - click(&mut clicked, 2, &mut out); + click(&mut clicked, 3, &mut out); assert_eq!( diff_tree_rows(&keyed), ["crates/tui/src", "docs", "*keys.md"] @@ -21225,23 +21185,95 @@ diff --git a/src/c.rs b/src/c.rs assert!(out.is_empty(), "the diff modal never talks to the daemon"); } + /// `g` on a clean checkout opens on HEAD's commit in the GRAPH, the + /// pane reading it. `Enter` unfolds it into its files right under it, + /// `↓` reads the first one against the commit's parent, and `←` / `Enter` + /// fold it back. #[test] - fn g_with_clean_repo_flashes_no_changes() { + fn g_with_clean_repo_opens_on_heads_commit() { let dir = tempfile::tempdir().unwrap(); let repo = test_repo(&dir); + std::fs::write(repo.join("a.txt"), "second\n").unwrap(); + run_git(&repo, &["commit", "-am", "second"]); let mut app = App::new(); seed_repo_tree(&mut app, &repo); let mut out = Vec::new(); press(&mut app, KeyCode::Char('g'), KeyModifiers::NONE, &mut out); - assert!(app.overlay.is_none(), "clean tree opens no modal"); - assert!( - app.flash - .as_deref() - .unwrap_or("") - .contains("no changes in main"), - "{:?}", - app.flash - ); + let Some(Overlay::Diff(v)) = &app.overlay else { + panic!("expected the modal, got {:?}", app.overlay); + }; + assert_eq!(v.place, Place::Graph); + let log = v.log.as_ref().unwrap(); + assert_eq!(log.commits.len(), 2); + assert_eq!(log.selected_commit().unwrap().subject, "second"); + assert!(v.diff.contains("second"), "{}", v.diff); + + press(&mut app, KeyCode::Enter, KeyModifiers::NONE, &mut out); + press(&mut app, KeyCode::Down, KeyModifiers::NONE, &mut out); + let Some(Overlay::Diff(v)) = &app.overlay else { + panic!("closed"); + }; + let log = v.log.as_ref().unwrap(); + assert!(matches!( + log.selected_entry(), + Some(crate::git_log::Entry::File(..)) + )); + assert!(v.diff.contains("-orig"), "{}", v.diff); + assert!(v.diff.contains("+second"), "{}", v.diff); + + press(&mut app, KeyCode::Left, KeyModifiers::NONE, &mut out); + press(&mut app, KeyCode::Left, KeyModifiers::NONE, &mut out); + let Some(Overlay::Diff(v)) = &app.overlay else { + panic!("closed"); + }; + let log = v.log.as_ref().unwrap(); + assert!(log.unfolded.is_empty(), "folded back"); + assert_eq!(log.selected_commit().unwrap().subject, "second"); + press(&mut app, KeyCode::Esc, KeyModifiers::NONE, &mut out); + assert!(app.overlay.is_none()); + } + + /// The CHANGES sit over the GRAPH in one list: `↓` off the last changed + /// file crosses the GRAPH's header onto its first commit, and typing + /// narrows both. + #[test] + fn the_cursor_walks_from_the_changes_into_the_graph() { + let dir = tempfile::tempdir().unwrap(); + let repo = test_repo(&dir); + std::fs::write(repo.join("a.txt"), "changed\n").unwrap(); + let mut app = App::new(); + seed_repo_tree(&mut app, &repo); + let mut out = Vec::new(); + press(&mut app, KeyCode::Char('g'), KeyModifiers::NONE, &mut out); + let Some(Overlay::Diff(v)) = &app.overlay else { + panic!("closed"); + }; + assert_eq!(v.place, Place::Changes); + assert_eq!(v.selected_path(), Some("a.txt")); + + press(&mut app, KeyCode::Down, KeyModifiers::NONE, &mut out); + let Some(Overlay::Diff(v)) = &app.overlay else { + panic!("closed"); + }; + assert_eq!(v.place, Place::GraphHeader); + assert!(v.diff.contains("a.txt"), "git status: {}", v.diff); + press(&mut app, KeyCode::Down, KeyModifiers::NONE, &mut out); + let Some(Overlay::Diff(v)) = &app.overlay else { + panic!("closed"); + }; + assert_eq!(v.place, Place::Graph); + assert!(v.diff.contains("init"), "the commit: {}", v.diff); + + // A filter only the commit matches keeps the cursor in the GRAPH. + for c in "init".chars() { + press(&mut app, KeyCode::Char(c), KeyModifiers::NONE, &mut out); + } + let Some(Overlay::Diff(v)) = &app.overlay else { + panic!("closed"); + }; + assert_eq!(v.row_count(), 0, "no changed file matches"); + assert_eq!(v.place, Place::Graph); + assert_eq!(v.log.as_ref().unwrap().rows.len(), 1); } /// `G` turns the checkout's remote into a page and hands it to the @@ -21876,7 +21908,7 @@ diff --git a/src/c.rs b/src/c.rs let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap(); terminal.draw(|f| ui::draw(f, &mut app)).unwrap(); let text = buffer_text(&terminal); - assert!(text.contains("Files (2)"), "file pane title:\n{text}"); + assert!(text.contains("CHANGES (2)"), "the changes' header:\n{text}"); assert!(text.contains("alpha.rs"), "file row:\n{text}"); assert!(text.contains("type to filter"), "filter row:\n{text}"); assert!(text.contains("+new line"), "diff body:\n{text}"); @@ -21943,14 +21975,15 @@ diff --git a/src/c.rs b/src/c.rs ); let mut out = Vec::new(); - // Click the second row: beta.rs becomes the selection and its diff - // loads (the fake root makes that an error string, still a reload). + // Click the second file, under the CHANGES header: beta.rs becomes + // the selection and its diff loads (the fake root makes that an + // error string, still a reload). handle_mouse( &mut app, mev( MouseEventKind::Down(MouseButton::Left), area.x + 2, - area.y + 1, + area.y + 2, ), &mut out, ); diff --git a/crates/nebula-tui/src/event_loop/activate.rs b/crates/nebula-tui/src/event_loop/activate.rs index 47190171..23063645 100644 --- a/crates/nebula-tui/src/event_loop/activate.rs +++ b/crates/nebula-tui/src/event_loop/activate.rs @@ -25,7 +25,7 @@ use super::{ WORKTREE_STILL_CREATING, }; use crate::app::{ - App, ConfirmDialog, DiffView, Focus, FollowUp, Overlay, PendingAction, SessionRow, + App, ConfirmDialog, DiffView, Focus, FollowUp, Overlay, PendingAction, Place, SessionRow, }; use nebula_core::{AgentId, ClientRequest, SessionRef, WorktreeId}; @@ -85,36 +85,43 @@ pub(super) fn metrics_row(app: &mut App, out: &mut Vec) { open_session(app, sref, out); } -/// Move the DIFF modal's file cursor to `index` (clamped) and read that -/// file's diff when the cursor actually moved — ↑/↓ and a click on a file -/// row alike. +/// Move the DIFF modal's sidebar cursor to row `index` (clamped), across +/// its CHANGES and GRAPH, and read what it lands on when it actually +/// moved: ↑/↓, the wheel and `Ctrl+d`/`Ctrl+u` alike. pub(super) fn diff_file(view: &mut DiffView, index: i64) { - if view.select(index) { + if view.side_select(index) { crate::git_diff::load_selected_diff(view); } } -/// A row of the DIFF modal's list chosen — a click on it, or Enter on the -/// cursor's own: the cursor lands there, and a tree directory's row folds -/// or unfolds as well. On a file's row that is all there is to choose, so -/// in the flat list this is `diff_file`. +/// A row of the DIFF modal's sidebar chosen (a click on it, or Enter on +/// the cursor's own): the cursor lands there, and a section header, a tree +/// directory or a GRAPH commit folds or unfolds as well. On a file's row +/// that is all there is to choose, so in the flat list this is `diff_file`. pub(super) fn diff_row(view: &mut DiffView, index: i64) { - let moved = view.select(index); - if view.toggle_dir(view.cursor()) || moved { + let moved = view.side_select(index); + let folded = match view.place { + Place::ChangesHeader | Place::GraphHeader => view.fold_section(None), + Place::Changes => view.toggle_dir(view.cursor()), + Place::Graph => crate::git_log::fold(view, None), + }; + if moved || folded { crate::git_diff::load_selected_diff(view); } } -/// `→` / `←` in the DIFF modal's tree: open or fold the directory under the -/// cursor, stepping into an open one or out to the parent's row, and read -/// whatever the cursor came to rest on. -pub(super) fn diff_tree_step(view: &mut DiffView, inward: bool) { - let moved = if inward { - view.expand_selected() - } else { - view.collapse_selected() +/// `→` / `←` in the DIFF modal: unfold or fold what the cursor is on (a +/// section, a GRAPH commit, a tree directory), stepping into an open one +/// or out to its parent's row, and read whatever the cursor came to rest +/// on. +pub(super) fn diff_fold(view: &mut DiffView, open: bool) { + let changed = match view.place { + Place::ChangesHeader | Place::GraphHeader => view.fold_section(Some(open)), + Place::Changes if open => view.expand_selected(), + Place::Changes => view.collapse_selected(), + Place::Graph => crate::git_log::fold(view, Some(open)), }; - if moved { + if changed { crate::git_diff::load_selected_diff(view); } } @@ -124,16 +131,33 @@ pub(super) fn diff_tree_step(view: &mut DiffView, inward: bool) { /// had to move — off a directory's row, which the flat list has none of — /// reads a diff. pub(super) fn diff_tree_toggled(view: &mut DiffView) { - if view.toggle_tree() { + if view.toggle_tree() && view.place == Place::Changes { crate::git_diff::load_selected_diff(view); } } /// The DIFF modal's filter text changed — typed, pasted, or cleared by -/// Esc: the file list narrows, and when that moved the cursor onto another -/// file its diff is read. +/// Esc: both sections narrow, and when that moved the cursor onto another +/// row its diff is read. A cursor whose section has nothing left goes to +/// the other's first row. pub(super) fn diff_filter_changed(view: &mut DiffView) { - if view.apply_filter() { + let before = view.selected_key(); + let moved = view.apply_filter(); + let query = view.filter.to_string(); + if let Some(log) = &mut view.log { + log.apply_filter(&query); + } + let graph_rows = view.log.as_ref().map_or(0, |l| l.rows.len()); + match view.place { + Place::Changes if view.row_count() == 0 && graph_rows > 0 => { + view.place = Place::Graph; + } + Place::Graph if graph_rows == 0 && view.row_count() > 0 => { + view.place = Place::Changes; + } + _ => {} + } + if view.selected_key() != before || (moved && view.place == Place::Changes) { crate::git_diff::load_selected_diff(view); } } diff --git a/crates/nebula-tui/src/git_diff.rs b/crates/nebula-tui/src/git_diff.rs index 2237342a..b95fd4ed 100644 --- a/crates/nebula-tui/src/git_diff.rs +++ b/crates/nebula-tui/src/git_diff.rs @@ -13,7 +13,7 @@ use std::path::Path; use std::process::{Command, Output}; /// Keep pathological diffs from bloating the overlay state. -const MAX_DIFF_LINES: usize = 20_000; +pub(crate) const MAX_DIFF_LINES: usize = 20_000; /// One changed file from `git status --porcelain=v1 -z`. #[derive(Debug, Clone, PartialEq)] @@ -311,10 +311,28 @@ pub fn head_oid(root: &Path) -> Option { .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string()) } -/// Diff text for one file. Never fails: errors become the displayed text so -/// the modal survives a repo vanishing out from under it. -pub fn diff_for(root: &Path, file: &DiffFile, head_ok: bool) -> String { - let output = if file.is_untracked() { +/// Diff text for one file: its uncommitted changes, or (given a commit) +/// what that commit changed in it against its first parent. Never fails: +/// errors become the displayed text so the modal survives a repo vanishing +/// out from under it. +pub fn diff_for(root: &Path, file: &DiffFile, head_ok: bool, commit: Option<&str>) -> String { + let output = if let Some(sha) = commit { + let mut args = vec![ + "show", + "--format=", + "--no-color", + "--no-ext-diff", + "-M", + "--diff-merges=first-parent", + sha, + "--", + &file.path, + ]; + if let Some(orig) = &file.orig_path { + args.push(orig); + } + run_git(root, &args) + } else if file.is_untracked() { // --no-index exits 1 when the files differ; only >= 2 is an error. run_git( root, @@ -387,7 +405,7 @@ pub fn read_listing(root: &Path) -> Result Result<(), String> { + let ticket = crate::view_jobs::ticket(); + view.listing = Some(ticket); + let root = view.root.clone(); + match view.jobs.clone() { + Some(jobs) => { + jobs.run(move || { + Some(crate::view_jobs::Answer::DiffListing { + ticket, + result: read_listing(&root), + }) + }); + Ok(()) + } + None => take_listing(view, read_listing(&root)), + } +} + +/// The changed files landed: fill the view, and put a cursor nobody has +/// moved yet on the first of them, or on HEAD's commit when there are +/// none. `Err` is git's refusal, for the caller to close the modal with. +pub fn take_listing( + view: &mut DiffView, + result: Result, +) -> Result<(), String> { + fill_view(view, result?); + if view.settle() { + load_selected_diff(view); + } + Ok(()) +} + /// Reload `view.diff` for the currently selected file and reset the scroll. /// A view whose diffs were fetched whole (a pull request) reads them out of /// `prefetched` instead of shelling out — there is no local commit to ask @@ -466,6 +519,9 @@ pub fn fill_view(view: &mut DiffView, listing: crate::view_jobs::DiffListing) { /// longer than a diff takes; past that the pane says `loading…`. Either way /// the answer comes back through [`land_diff`]. pub fn load_selected_diff(view: &mut DiffView) { + if view.place != crate::app::Place::Changes { + return crate::git_log::load_selected(view); + } view.waiting = None; let Some(file) = view.selected_file().cloned() else { let summary = view.dir_summary().unwrap_or_default(); @@ -481,7 +537,7 @@ pub fn load_selected_diff(view: &mut DiffView) { return; } let Some(jobs) = view.jobs.clone() else { - let diff = diff_for(&view.root, &file, view.head_ok); + let diff = diff_for(&view.root, &file, view.head_ok, None); view.show_diff(Some(&file.path), diff, false); return; }; @@ -505,7 +561,7 @@ fn request_diff( Some(crate::view_jobs::Answer::DiffText { view: id, ticket, - diff: diff_for(&root, &file, head_ok), + diff: diff_for(&root, &file, head_ok, None), path: file.path, prefetch, }) @@ -542,6 +598,9 @@ pub fn land_diff( if !(same_file && view.diff == diff) { view.show_diff(Some(path), diff, same_file); } + if view.place != crate::app::Place::Changes { + return crate::git_log::read_ahead(view); + } let next = view .file_after_cursor() .filter(|file| view.cached(&file.path).is_none()) @@ -554,18 +613,141 @@ pub fn land_diff( /// The diff in flight has outlasted the grace the last file's text was kept /// for: say so, rather than leave one file's diff under another's name. pub fn diff_slow(view: &mut DiffView, ticket: u64) { - if view.waiting == Some(ticket) - && view.shown.as_deref() != view.selected_file().map(|f| f.path.as_str()) - { + if view.waiting == Some(ticket) && view.shown != view.selected_key() { view.show_diff(None, "loading…".to_string(), false); } } +/// One row of a diff laid out side by side: the old file left, the new +/// one right. +#[derive(Debug, Clone, PartialEq)] +pub enum SplitRow { + /// A hunk header, or a line about the file (a rename, a mode change), + /// across both sides. + Note(String, DiffLineKind), + /// A line of each side, numbered; `None` is a side with nothing facing + /// the other's added or removed line. `changed` sets a removed line + /// against its replacement, rather than one line both sides share. + Pair { + left: Option<(u32, String)>, + right: Option<(u32, String)>, + changed: bool, + }, +} + +/// A unified diff laid out side by side: context lines face themselves, +/// and each run of removed lines faces the added lines that follow it, row +/// by row. The `diff --git` / `index` / `---` / `+++` headers say nothing +/// the pane's title does not, and are dropped. `None` for text with no +/// hunk in it (a commit's summary, a status, an error), which is shown as +/// it is. +pub fn split_rows(diff: &str) -> Option> { + if !diff.lines().any(|l| l.starts_with("@@")) { + return None; + } + let mut rows = Vec::new(); + let (mut old, mut new) = (0u32, 0u32); + let mut removed: Vec<(u32, String)> = Vec::new(); + let mut added: Vec<(u32, String)> = Vec::new(); + let flush = |rows: &mut Vec, removed: &mut Vec<_>, added: &mut Vec<_>| { + let n = removed.len().max(added.len()); + let mut left = removed.drain(..); + let mut right = added.drain(..); + for _ in 0..n { + rows.push(SplitRow::Pair { + left: left.next(), + right: right.next(), + changed: true, + }); + } + }; + let mut in_hunk = false; + for line in diff.lines() { + if let Some(rest) = line.strip_prefix("@@") { + flush(&mut rows, &mut removed, &mut added); + // `@@ -12,7 +12,8 @@ fn context`: where each side picks up. + let mut starts = rest.split_whitespace().take(2).map(|r| { + r.get(1..) + .and_then(|r| r.split(',').next()) + .and_then(|n| n.parse::().ok()) + .unwrap_or(1) + }); + old = starts.next().unwrap_or(1); + new = starts.next().unwrap_or(1); + rows.push(SplitRow::Note(line.to_string(), DiffLineKind::Hunk)); + in_hunk = true; + } else if line.starts_with("diff --git") { + flush(&mut rows, &mut removed, &mut added); + in_hunk = false; + } else if !in_hunk { + const DROPPED: [&str; 3] = ["index ", "--- ", "+++ "]; + if !DROPPED.iter().any(|d| line.starts_with(d)) { + rows.push(SplitRow::Note(line.to_string(), DiffLineKind::Header)); + } + } else if let Some(text) = line.strip_prefix('-') { + removed.push((old, text.to_string())); + old += 1; + } else if let Some(text) = line.strip_prefix('+') { + added.push((new, text.to_string())); + new += 1; + } else if line.starts_with('\\') { + // `\ No newline at end of file` + flush(&mut rows, &mut removed, &mut added); + rows.push(SplitRow::Note(line.to_string(), DiffLineKind::Header)); + } else { + flush(&mut rows, &mut removed, &mut added); + let text = line.strip_prefix(' ').unwrap_or(line).to_string(); + rows.push(SplitRow::Pair { + left: Some((old, text.clone())), + right: Some((new, text)), + changed: false, + }); + old += 1; + new += 1; + } + } + flush(&mut rows, &mut removed, &mut added); + Some(rows) +} + #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; + /// Removed lines face the added lines after them, numbered from the + /// hunk header; context faces itself; the file headers are dropped and + /// a mode line is kept. + #[test] + fn split_rows_pairs_removals_with_the_additions_after_them() { + let diff = "diff --git a/x b/x\nold mode 100644\nnew mode 100755\nindex 1..2\n--- a/x\n+++ b/x\n\ + @@ -10,4 +10,5 @@ fn f\n keep\n-gone one\n-gone two\n+new one\n+new two\n+new three\n tail\n"; + let rows = split_rows(diff).unwrap(); + let pair = |l: Option<(u32, &str)>, r: Option<(u32, &str)>, changed| SplitRow::Pair { + left: l.map(|(n, t)| (n, t.to_string())), + right: r.map(|(n, t)| (n, t.to_string())), + changed, + }; + assert_eq!( + rows, + vec![ + SplitRow::Note("old mode 100644".into(), DiffLineKind::Header), + SplitRow::Note("new mode 100755".into(), DiffLineKind::Header), + SplitRow::Note("@@ -10,4 +10,5 @@ fn f".into(), DiffLineKind::Hunk), + pair(Some((10, "keep")), Some((10, "keep")), false), + pair(Some((11, "gone one")), Some((11, "new one")), true), + pair(Some((12, "gone two")), Some((12, "new two")), true), + pair(None, Some((13, "new three")), true), + pair(Some((13, "tail")), Some((14, "tail")), false), + ] + ); + assert_eq!( + split_rows("commit abc\n\n subject"), + None, + "no hunk: shown as it is" + ); + } + fn modified(path: &str) -> DiffFile { DiffFile { path: path.into(), @@ -794,11 +976,11 @@ mod tests { assert!(fresh.is_untracked()); assert!(has_head(&repo)); - let diff = diff_for(&repo, tracked, true); + let diff = diff_for(&repo, tracked, true, None); assert!(diff.contains("-old line"), "{diff}"); assert!(diff.contains("+new line"), "{diff}"); // Untracked goes through the --no-index exit-1 path. - let diff = diff_for(&repo, fresh, true); + let diff = diff_for(&repo, fresh, true, None); assert!(diff.contains("+hello"), "{diff}"); } @@ -847,7 +1029,7 @@ mod tests { let files = changed_files(&repo).unwrap(); assert_eq!(files.len(), 1); assert!(files[0].is_untracked()); - let diff = diff_for(&repo, &files[0], false); + let diff = diff_for(&repo, &files[0], false, None); assert!(diff.contains("+content"), "{diff}"); } diff --git a/crates/nebula-tui/src/git_log.rs b/crates/nebula-tui/src/git_log.rs new file mode 100644 index 00000000..edecf422 --- /dev/null +++ b/crates/nebula-tui/src/git_log.rs @@ -0,0 +1,1071 @@ +//! The GRAPH: the lower section of the DIFF VIEWER's SOURCE CONTROL +//! sidebar (`g`), under the CHANGES. The checkout's commits (HEAD, every +//! local branch and every remote-tracking one, newest first and drawn the +//! way `git log --graph` draws them) with each commit's refs, author and +//! age, and an arrow on the ones HEAD's upstream does not share yet: `↑` +//! for a commit a push would send, `↓` for one a pull would bring. +//! +//! The cursor's commit is read in the right pane (`git show --stat`). +//! `Enter`, `→` or a click unfolds a commit into its files right under it, +//! each diffed against the commit's first parent when the cursor is on it. +//! Reading only: nothing in this module writes to the repository. + +use crate::app::{DiffView, Place}; +use crate::git_diff::{cap_lines, run_git, DiffFile, MAX_DIFF_LINES}; +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; + +/// The most commits one read lists, newest first: a branch's recent story, +/// read in well under a second on a large repository. The whole history +/// is `git log`'s job. +const MAX_COMMITS: &str = "500"; + +/// Between the fields of a log line; git puts none in a ref or a subject. +const SEP: char = '\u{1f}'; + +/// The cache and `DiffView::shown` key of the checkout's `git status`, what +/// the right pane reads on a section header: no commit hash or path can +/// look like it. +pub const WORKING: &str = ":working"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RefKind { + /// HEAD, with the branch it is on (an empty name when detached). + Head, + Local, + Remote, + Tag, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Ref { + pub kind: RefKind, + pub name: String, +} + +impl Ref { + pub fn label(&self) -> String { + match (self.kind, self.name.as_str()) { + (RefKind::Head, "") => "HEAD".into(), + (RefKind::Head, branch) => format!("HEAD → {branch}"), + (RefKind::Tag, tag) => format!("tag: {tag}"), + (_, name) => name.into(), + } + } +} + +/// Where a commit sits against HEAD's upstream branch. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum Side { + /// On both, or there is no upstream to compare with. + #[default] + Shared, + /// On HEAD only: a push would send it. + Ahead, + /// On the upstream only: a pull would bring it. + Behind, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Commit { + pub sha: String, + pub short: String, + pub refs: Vec, + pub author: String, + /// Committer date, unix seconds. + pub time: i64, + pub subject: String, + pub side: Side, +} + +impl Commit { + /// `(HEAD → main, origin/main)`, empty for a commit no ref points at. + pub fn refs_label(&self) -> String { + if self.refs.is_empty() { + return String::new(); + } + let labels: Vec = self.refs.iter().map(Ref::label).collect(); + format!("({})", labels.join(", ")) + } + + /// What the filter matches and a filtered row shows: the short hash, + /// the refs and the subject. + pub fn haystack(&self) -> String { + let refs = self.refs_label(); + if refs.is_empty() { + format!("{} {}", self.short, self.subject) + } else { + format!("{} {refs} {}", self.short, self.subject) + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Entry { + Commit(usize), + /// One of an unfolded commit's files: (commit, file). + File(usize, usize), + /// Under an unfolded commit with no files to list: still being read, + /// none changed, or git's refusal. + Note(usize), + /// A line of graph between two commits. + Graph, +} + +impl Entry { + /// Rows the cursor rests on; it steps over the rest. + pub fn selectable(self) -> bool { + matches!(self, Entry::Commit(_) | Entry::File(..)) + } +} + +#[derive(Debug, Clone)] +pub struct LogRow { + pub entry: Entry, + /// The `git log --graph` line this row draws (an index into + /// `GitLog::lines`; a file row repeats its commit's); none while the + /// filter narrows the list. + pub line: Option, + /// The filter's matched char positions in the commit's `haystack`. + pub positions: Vec, +} + +/// An unfolded commit's files: `None` while they are read. +pub type Unfolded = Option, String>>; + +/// One read of a checkout's history, off the loop. +#[derive(Debug, Default)] +pub struct LogRead { + pub commits: Vec, + pub lines: Vec<(String, Option)>, +} + +/// The GRAPH section. Shared behind `Arc`s: the views are cloned every +/// frame. +#[derive(Debug, Clone, Default)] +pub struct GitLog { + pub commits: Arc<[Commit]>, + /// Every line `git log --graph` drew: the graph, and the commit on the + /// line (none on a line that only connects two). + pub lines: Arc<[(String, Option)]>, + /// The rows on screen: every graph line, or the commits the filter + /// matched, best first; each unfolded commit followed by its files. + pub rows: Vec, + pub selected: usize, + /// The filter `rows` were built for (the DIFF VIEWER's own). + query: String, + /// Commits unfolded into their files, by hash. + pub unfolded: HashMap, + /// The read in flight, by ticket. + pub reading: Option, + /// Why the last read failed. + pub error: Option, +} + +impl GitLog { + pub fn selected_entry(&self) -> Option { + self.rows.get(self.selected).map(|r| r.entry) + } + + /// The cursor's commit, or the commit of the file it is on. + pub fn selected_commit(&self) -> Option<&Commit> { + match self.selected_entry()? { + Entry::Commit(c) | Entry::File(c, _) => self.commits.get(c), + _ => None, + } + } + + pub fn file_at(&self, commit: usize, file: usize) -> Option<&DiffFile> { + let sha = &self.commits.get(commit)?.sha; + match self.unfolded.get(sha)? { + Some(Ok(files)) => files.get(file), + _ => None, + } + } + + /// What a row reads in the right pane, as a cache key: a commit's hash, + /// or `hash:path` for one of its files. + fn key_of(&self, entry: Entry) -> Option { + match entry { + Entry::Commit(c) => Some(self.commits.get(c)?.sha.clone()), + Entry::File(c, f) => Some(format!( + "{}:{}", + self.commits.get(c)?.sha, + self.file_at(c, f)?.path + )), + _ => None, + } + } + + pub fn selected_key(&self) -> Option { + self.key_of(self.selected_entry()?) + } + + /// What an unfolded commit with no file rows says instead. + pub fn note(&self, commit: usize) -> String { + let unfolded = self + .commits + .get(commit) + .and_then(|c| self.unfolded.get(&c.sha)); + match unfolded { + Some(Some(Err(msg))) => msg.clone(), + Some(Some(Ok(_))) => "no file changes".into(), + _ => "loading…".into(), + } + } + + /// How many commits HEAD's upstream lacks, and how many HEAD lacks. + pub fn sides(&self) -> (usize, usize) { + let count = |side| self.commits.iter().filter(|c| c.side == side).count(); + (count(Side::Ahead), count(Side::Behind)) + } + + /// The next commit down from the cursor: what is read ahead. + pub fn commit_after_cursor(&self) -> Option<&Commit> { + self.rows + .iter() + .skip(self.selected + 1) + .find_map(|r| match r.entry { + Entry::Commit(c) => self.commits.get(c), + _ => None, + }) + } + + /// Put the cursor on row `target` (clamped), or on the nearest row it + /// can rest on in the way it is moving. True when it moved. + pub fn select_toward(&mut self, target: usize, down: bool) -> bool { + let Some(last) = self.rows.len().checked_sub(1) else { + return false; + }; + let target = target.min(last); + let ok = |i: &usize| self.rows[*i].entry.selectable(); + let below = (target..self.rows.len()).find(ok); + let above = (0..=target).rev().find(ok); + let found = if down { + below.or(above) + } else { + above.or(below) + }; + let Some(row) = found else { + return false; + }; + let moved = row != self.selected; + self.selected = row; + moved + } + + /// [`GitLog::select_toward`] row `index`, moving the way it lies. + pub fn select(&mut self, index: i64) -> bool { + let down = index >= self.selected as i64; + self.select_toward(index.max(0) as usize, down) + } + + fn select_key(&mut self, key: &str) -> bool { + let row = + (0..self.rows.len()).find(|&i| self.key_of(self.rows[i].entry).as_deref() == Some(key)); + if let Some(row) = row { + self.selected = row; + } + row.is_some() + } + + pub fn select_sha(&mut self, sha: &str) -> bool { + self.select_key(sha) + } + + /// Where the cursor starts: HEAD's commit. + pub fn go_home(&mut self) { + let head = self.rows.iter().position(|r| match r.entry { + Entry::Commit(c) => self.commits[c] + .refs + .iter() + .any(|rf| rf.kind == RefKind::Head), + _ => false, + }); + self.selected = 0; + if let Some(row) = head { + self.selected = row; + } else { + self.select_toward(0, true); + } + } + + /// Rebuild the rows for the filter `query`. An empty one lists every + /// graph line and keeps the cursor's row if it is still there (or goes + /// home); one with text lands on the best match. True when the cursor + /// reads something else than it did. + pub fn apply_filter(&mut self, query: &str) -> bool { + let before = self.selected_key(); + self.query = query.to_string(); + self.rebuild(); + let kept = query.trim().is_empty() && before.as_deref().is_some_and(|k| self.select_key(k)); + if !kept { + if query.trim().is_empty() { + self.go_home(); + } else { + self.selected = 0; + self.select_toward(0, true); + } + } + self.selected_key() != before + } + + /// Rebuild the rows as they stand (a commit folded, its files read), + /// the cursor kept on what it reads, or near where it was. + fn refresh(&mut self) { + let before = self.selected_key(); + let row = self.selected; + self.rebuild(); + if !before.as_deref().is_some_and(|k| self.select_key(k)) { + self.select_toward(row, false); + } + } + + fn rebuild(&mut self) { + let mut rows = Vec::new(); + if self.query.trim().is_empty() { + for (n, (_, commit)) in self.lines.iter().enumerate() { + match commit { + Some(c) => self.push_commit(&mut rows, *c, Some(n), Vec::new()), + None => rows.push(LogRow { + entry: Entry::Graph, + line: Some(n), + positions: Vec::new(), + }), + } + } + } else { + let hay: Vec = self.commits.iter().map(Commit::haystack).collect(); + for (c, positions) in crate::fuzzy::rank(&self.query, hay.iter().map(String::as_str)) { + self.push_commit(&mut rows, c, None, positions); + } + } + self.rows = rows; + } + + fn push_commit( + &self, + rows: &mut Vec, + c: usize, + line: Option, + positions: Vec, + ) { + rows.push(LogRow { + entry: Entry::Commit(c), + line, + positions, + }); + let under = |entry| LogRow { + entry, + line, + positions: Vec::new(), + }; + match self.unfolded.get(&self.commits[c].sha) { + None => {} + Some(Some(Ok(files))) if !files.is_empty() => { + rows.extend((0..files.len()).map(|f| under(Entry::File(c, f)))); + } + Some(_) => rows.push(under(Entry::Note(c))), + } + } + + /// A read landed: the list is replaced and the cursor stays on what it + /// read when the fresh list still has it. + fn land(&mut self, read: LogRead) { + let before = self.selected_key(); + self.commits = read.commits.into(); + self.lines = read.lines.into(); + self.error = None; + self.rebuild(); + if !before.as_deref().is_some_and(|k| self.select_key(k)) { + self.go_home(); + } + } +} + +/// `git log --graph` over HEAD, every local branch and every remote one. +pub fn read_log(root: &Path) -> Result { + if !crate::git_diff::has_head(root) { + return Ok(LogRead::default()); + } + let output = run_git( + root, + &[ + "log", + "--graph", + "--date-order", + "--decorate=full", + "--no-color", + "--format=%x1f%H%x1f%h%x1f%D%x1f%an%x1f%ct%x1f%s", + "-n", + MAX_COMMITS, + "HEAD", + "--branches", + "--remotes", + "--", + ], + )?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("git log failed: {}", stderr.trim())); + } + let (mut commits, lines) = parse_log(&String::from_utf8_lossy(&output.stdout)); + mark_sides(root, &mut commits); + Ok(LogRead { commits, lines }) +} + +/// Split `git log --graph` output into its commits and its lines: a line +/// carrying the field separator is a commit's (the graph left of it), one +/// without only connects two. +pub fn parse_log(text: &str) -> (Vec, Vec<(String, Option)>) { + let mut commits = Vec::new(); + let mut lines = Vec::new(); + for line in text.lines() { + let Some((graph, rest)) = line.split_once(SEP) else { + lines.push((line.trim_end().to_string(), None)); + continue; + }; + let f: Vec<&str> = rest.splitn(6, SEP).collect(); + let [sha, short, refs, author, time, subject] = f[..] else { + continue; + }; + lines.push((graph.trim_end().to_string(), Some(commits.len()))); + commits.push(Commit { + sha: sha.into(), + short: short.into(), + refs: parse_refs(refs), + author: author.into(), + time: time.parse().unwrap_or(0), + subject: subject.into(), + side: Side::Shared, + }); + } + (commits, lines) +} + +/// `%D` under `--decorate=full`: `HEAD -> refs/heads/main, refs/remotes/ +/// origin/main, tag: refs/tags/v1`. A remote's `HEAD` says nothing its +/// branch does not, and refs outside branches and tags (a stash) are +/// left out. +pub fn parse_refs(decorations: &str) -> Vec { + decorations + .split(", ") + .filter_map(|d| { + let (kind, name) = if let Some(branch) = d.strip_prefix("HEAD -> ") { + ( + RefKind::Head, + branch.strip_prefix("refs/heads/").unwrap_or(branch), + ) + } else if d == "HEAD" { + (RefKind::Head, "") + } else if let Some(tag) = d.strip_prefix("tag: ") { + (RefKind::Tag, tag.strip_prefix("refs/tags/").unwrap_or(tag)) + } else if let Some(branch) = d.strip_prefix("refs/heads/") { + (RefKind::Local, branch) + } else { + let remote = d + .strip_prefix("refs/remotes/") + .filter(|r| !r.ends_with("/HEAD"))?; + (RefKind::Remote, remote) + }; + Some(Ref { + kind, + name: name.into(), + }) + }) + .collect() +} + +/// Arrow the commits HEAD and its upstream do not share. No upstream (or a +/// detached HEAD) marks nothing. +fn mark_sides(root: &Path, commits: &mut [Commit]) { + let Ok(output) = run_git(root, &["rev-list", "--left-right", "HEAD...@{upstream}"]) else { + return; + }; + if !output.status.success() { + return; + } + let text = String::from_utf8_lossy(&output.stdout); + let sides: HashMap<&str, Side> = text + .lines() + .filter_map(|l| match l.split_at_checked(1)? { + ("<", sha) => Some((sha, Side::Ahead)), + (">", sha) => Some((sha, Side::Behind)), + _ => None, + }) + .collect(); + for commit in commits { + if let Some(side) = sides.get(commit.sha.as_str()) { + commit.side = *side; + } + } +} + +/// The files a commit changed against its first parent (a root commit: +/// everything it added). +pub fn read_commit_files(root: &Path, sha: &str) -> Result, String> { + let output = run_git( + root, + &[ + "show", + "--format=", + "--name-status", + "-z", + "-M", + "--diff-merges=first-parent", + sha, + "--", + ], + )?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("git show failed: {}", stderr.trim())); + } + Ok(parse_name_status_z(&output.stdout)) +} + +/// `--name-status -z`: `M\0path\0`, and for a rename or copy +/// `R100\0old\0new\0`. +pub fn parse_name_status_z(bytes: &[u8]) -> Vec { + let mut fields = bytes + .split(|b| *b == 0) + .map(|f| String::from_utf8_lossy(f).into_owned()); + let mut files = Vec::new(); + while let Some(status) = fields.next() { + let Some(x) = status.chars().next() else { + continue; + }; + let Some(first) = fields.next() else { + break; + }; + let (path, orig_path) = if matches!(x, 'R' | 'C') { + match fields.next() { + Some(new) => (new, Some(first)), + None => break, + } + } else { + (first, None) + }; + files.push(DiffFile { + path, + orig_path, + xy: [x, ' '], + }); + } + files +} + +/// The right pane for a commit: its header, message and stat; for +/// [`WORKING`], the checkout's `git status` with where it stands against +/// its upstream. Never fails: an error is the text shown. +pub fn summary_for(root: &Path, key: &str) -> String { + let output = if key == WORKING { + run_git(root, &["status", "--short", "--branch"]) + } else { + run_git( + root, + &[ + "show", + "--stat", + "--decorate=short", + "--format=fuller", + "--no-color", + "--diff-merges=first-parent", + key, + "--", + ], + ) + }; + match output { + Ok(o) if o.status.success() => { + cap_lines(&String::from_utf8_lossy(&o.stdout), MAX_DIFF_LINES, false) + } + Ok(o) => format!("git failed: {}", String::from_utf8_lossy(&o.stderr).trim()), + Err(e) => e, + } +} + +/// Read the GRAPH (again: a commit or a fetch may have landed since), the +/// last read showing until it lands. +pub fn request_log(view: &mut DiffView) { + let ticket = crate::view_jobs::ticket(); + let log = view.log.get_or_insert_with(GitLog::default); + log.reading = Some(ticket); + let root = view.root.clone(); + match view.jobs.clone() { + Some(jobs) => jobs.run(move || { + Some(crate::view_jobs::Answer::Log { + ticket, + result: read_log(&root), + }) + }), + None => land_log(view, ticket, read_log(&root)), + } +} + +/// The history read came back. +pub fn land_log(view: &mut DiffView, ticket: u64, result: Result) { + let Some(log) = &mut view.log else { + return; + }; + if log.reading != Some(ticket) { + return; + } + log.reading = None; + match result { + Ok(read) => { + log.land(read); + let query = view.filter.to_string(); + if !query.trim().is_empty() { + if let Some(log) = &mut view.log { + log.apply_filter(&query); + } + } + } + Err(msg) => log.error = Some(msg), + } + if view.settle() || view.place == Place::Graph { + crate::git_diff::load_selected_diff(view); + } +} + +/// Unfold the cursor's commit into its files, reading them, or fold it +/// (`open` None flips it). `→` on an unfolded commit steps onto its first +/// file and `←` on a file steps back up onto its commit. True when the +/// cursor's row or the rows changed. +pub fn fold(view: &mut DiffView, open: Option) -> bool { + let Some(log) = &mut view.log else { + return false; + }; + let (c, on_file) = match log.selected_entry() { + Some(Entry::Commit(c)) => (c, false), + Some(Entry::File(c, _)) => (c, true), + _ => return false, + }; + let sha = log.commits[c].sha.clone(); + let is_open = log.unfolded.contains_key(&sha); + if on_file { + return open == Some(false) && log.select_sha(&sha); + } + match (open, is_open) { + (Some(true), true) => { + let first = log.rows.get(log.selected + 1).map(|r| r.entry); + matches!(first, Some(Entry::File(..))) && log.select(log.selected as i64 + 1) + } + (Some(false), false) => false, + (_, true) => { + log.unfolded.remove(&sha); + log.refresh(); + true + } + (_, false) => { + log.unfolded.insert(sha.clone(), None); + log.refresh(); + request_files(view, sha); + true + } + } +} + +fn request_files(view: &mut DiffView, sha: String) { + let (root, id) = (view.root.clone(), view.id); + match view.jobs.clone() { + Some(jobs) => jobs.run(move || { + Some(crate::view_jobs::Answer::CommitFiles { + view: id, + result: read_commit_files(&root, &sha), + sha, + }) + }), + None => { + let result = read_commit_files(&root, &sha); + land_files(view, id, &sha, result); + } + } +} + +/// An unfolded commit's files came back: they go in under it, if it is +/// still unfolded. +pub fn land_files(view: &mut DiffView, id: u64, sha: &str, result: Result, String>) { + if id != view.id { + return; + } + let Some(log) = &mut view.log else { + return; + }; + match log.unfolded.get_mut(sha) { + Some(slot) if slot.is_none() => *slot = Some(result), + _ => return, + } + log.refresh(); +} + +/// What the right pane reads off the loop for a row outside the CHANGES. +enum Read { + Summary(String), + File(String, DiffFile), +} + +impl Read { + fn key(&self) -> String { + match self { + Read::Summary(key) => key.clone(), + Read::File(sha, file) => format!("{sha}:{}", file.path), + } + } + + fn run(&self, root: &Path) -> String { + match self { + Read::Summary(key) => summary_for(root, key), + Read::File(sha, file) => crate::git_diff::diff_for(root, file, true, Some(sha)), + } + } +} + +/// Read the right pane for the cursor when it is not on a changed file: a +/// section header's `git status`, a commit, or one of its files. A commit +/// never changes, so one read of it is the last; the status is read again +/// each time, as a changed file's diff is. +pub fn load_selected(view: &mut DiffView) { + view.waiting = None; + let read = match view.place { + Place::Graph => { + let Some(log) = &view.log else { + return; + }; + match log.selected_entry() { + Some(Entry::Commit(c)) => Read::Summary(log.commits[c].sha.clone()), + Some(Entry::File(c, f)) => match log.file_at(c, f) { + Some(file) => Read::File(log.commits[c].sha.clone(), file.clone()), + None => return, + }, + _ => { + let text = match (&log.error, log.reading) { + (Some(msg), _) => msg.clone(), + (None, None) if log.commits.is_empty() => "no commits yet".into(), + _ => String::new(), + }; + view.show_diff(None, text, false); + return; + } + } + } + _ => Read::Summary(WORKING.into()), + }; + let key = read.key(); + let Some(jobs) = view.jobs.clone() else { + let text = read.run(&view.root); + view.show_diff(Some(&key), text, false); + return; + }; + if let Some(text) = view.cached(&key) { + view.show_diff(Some(&key), text.to_string(), false); + if key != WORKING { + return; + } + } + let ticket = crate::view_jobs::ticket(); + view.waiting = Some(ticket); + request(view, &jobs, read, ticket, false); +} + +/// The commit after the cursor, read ahead once the cursor's has landed. +pub fn read_ahead(view: &DiffView) { + if view.place != Place::Graph { + return; + } + let next = view + .log + .as_ref() + .and_then(GitLog::commit_after_cursor) + .map(|c| c.sha.clone()) + .filter(|sha| view.cached(sha).is_none()); + if let (Some(sha), Some(jobs)) = (next, view.jobs.clone()) { + request( + view, + &jobs, + Read::Summary(sha), + crate::view_jobs::ticket(), + true, + ); + } +} + +fn request( + view: &DiffView, + jobs: &crate::view_jobs::Jobs, + read: Read, + ticket: u64, + prefetch: bool, +) { + let (root, id) = (view.root.clone(), view.id); + let work = move || { + Some(crate::view_jobs::Answer::DiffText { + view: id, + ticket, + diff: read.run(&root), + path: read.key(), + prefetch, + }) + }; + if prefetch { + jobs.run(work); + } else { + jobs.run_with_grace(ticket, work); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn commit(sha: &str, refs: Vec) -> Commit { + Commit { + sha: sha.into(), + short: sha.into(), + refs, + author: "a".into(), + time: 0, + subject: format!("subject {sha}"), + side: Side::Shared, + } + } + + fn head() -> Vec { + vec![Ref { + kind: RefKind::Head, + name: "main".into(), + }] + } + + /// A log as `git log --graph` lays out a merge: two commits side by + /// side, the lines joining them, HEAD on the third. + fn log() -> GitLog { + let read = LogRead { + commits: vec![ + commit("c0", Vec::new()), + commit("c1", Vec::new()), + commit("c2", head()), + ], + lines: vec![ + ("*".into(), Some(0)), + ("|\\".into(), None), + ("| *".into(), Some(1)), + ("|/".into(), None), + ("*".into(), Some(2)), + ], + }; + let mut log = GitLog::default(); + log.land(read); + log + } + + fn modified(path: &str) -> DiffFile { + DiffFile { + path: path.into(), + orig_path: None, + xy: ['M', ' '], + } + } + + #[test] + fn parse_log_splits_commits_from_the_lines_between_them() { + let text = "* \x1faaaa\x1faa\x1fHEAD -> refs/heads/main, refs/remotes/origin/main\x1fAnn\x1f100\x1ffix: a\x1fb\n\ + |\\ \n\ + | * \x1fbbbb\x1fbb\x1f\x1fBob\x1f90\x1fadd b\n"; + let (commits, lines) = parse_log(text); + assert_eq!(commits.len(), 2); + assert_eq!( + commits[0].subject, "fix: a\x1fb", + "the subject keeps a stray separator" + ); + assert_eq!(commits[0].time, 100); + assert_eq!(commits[0].refs_label(), "(HEAD → main, origin/main)"); + assert!(commits[1].refs.is_empty()); + assert_eq!( + lines, + vec![ + ("*".to_string(), Some(0)), + ("|\\".to_string(), None), + ("| *".to_string(), Some(1)), + ] + ); + } + + #[test] + fn parse_refs_names_each_kind_and_drops_a_remotes_head() { + let refs = parse_refs( + "HEAD -> refs/heads/main, refs/remotes/origin/main, refs/remotes/origin/HEAD, \ + tag: refs/tags/v1, refs/heads/feat, refs/stash", + ); + let kinds: Vec<(RefKind, &str)> = refs.iter().map(|r| (r.kind, r.name.as_str())).collect(); + assert_eq!( + kinds, + vec![ + (RefKind::Head, "main"), + (RefKind::Remote, "origin/main"), + (RefKind::Tag, "v1"), + (RefKind::Local, "feat"), + ] + ); + assert_eq!(parse_refs("HEAD")[0].label(), "HEAD", "detached"); + assert!(parse_refs("").is_empty()); + } + + #[test] + fn parse_name_status_z_reads_renames_old_then_new() { + let files = parse_name_status_z(b"M\0src/a.rs\0R100\0old.rs\0new.rs\0A\0b\0"); + assert_eq!(files.len(), 3); + assert_eq!(files[0].xy, ['M', ' ']); + assert_eq!(files[1].path, "new.rs"); + assert_eq!(files[1].orig_path.as_deref(), Some("old.rs")); + assert_eq!(files[2].path, "b"); + assert!(parse_name_status_z(b"").is_empty()); + } + + /// The cursor starts on HEAD's commit, and walking it steps over the + /// graph's connecting lines whichever way it goes. + #[test] + fn the_cursor_starts_on_head_and_steps_over_graph_lines() { + let mut log = log(); + assert_eq!(log.selected_key().as_deref(), Some("c2")); + assert!(log.select(log.selected as i64 - 1)); + assert_eq!(log.selected_key().as_deref(), Some("c1"), "up over |/"); + assert!(log.select(log.selected as i64 - 1)); + assert_eq!(log.selected_key().as_deref(), Some("c0"), "up over |\\"); + assert!(!log.select(-5), "clamped at the top"); + assert!(log.select(1)); + assert_eq!(log.selected_key().as_deref(), Some("c1"), "down over |\\"); + assert_eq!( + log.commit_after_cursor().map(|c| c.sha.as_str()), + Some("c2") + ); + } + + /// Typing narrows the list to matching commits, best first and with no + /// graph; clearing it puts the graph back with the cursor where it was. + #[test] + fn the_filter_narrows_to_commits_and_clearing_keeps_the_cursor() { + let mut log = log(); + assert!(log.apply_filter("c1")); + assert_eq!(log.rows.len(), 1); + assert_eq!(log.selected_key().as_deref(), Some("c1")); + assert!(log.rows[0].line.is_none()); + + assert!(!log.apply_filter(""), "still on c1"); + assert_eq!(log.rows.len(), 5); + assert_eq!(log.selected_key().as_deref(), Some("c1")); + } + + /// A fresh read keeps the cursor on its commit. + #[test] + fn a_fresh_read_keeps_the_cursor_on_its_commit() { + let mut log = log(); + log.select_sha("c0"); + let read = LogRead { + commits: vec![commit("new", head()), commit("c0", Vec::new())], + lines: vec![("*".into(), Some(0)), ("*".into(), Some(1))], + }; + log.land(read); + assert_eq!(log.selected_key().as_deref(), Some("c0")); + } + + /// An unfolded commit lists its files right under it, each on its own + /// key; folding it again puts the cursor back on the commit. + #[test] + fn an_unfolded_commit_lists_its_files_under_it() { + let mut log = log(); + log.select_sha("c1"); + log.unfolded.insert("c1".into(), None); + log.refresh(); + assert_eq!(log.rows[3].entry, Entry::Note(1), "loading…"); + assert_eq!(log.note(1), "loading…"); + + *log.unfolded.get_mut("c1").unwrap() = Some(Ok(vec![modified("a.rs"), modified("b.rs")])); + log.refresh(); + assert_eq!( + log.selected_key().as_deref(), + Some("c1"), + "the cursor kept its row" + ); + assert!(log.select(log.selected as i64 + 1)); + assert_eq!(log.selected_key().as_deref(), Some("c1:a.rs")); + assert_eq!(log.selected_commit().map(|c| c.sha.as_str()), Some("c1")); + + log.unfolded.remove("c1"); + log.refresh(); + assert_eq!(log.rows.len(), 5); + assert!(log.selected_entry().is_some_and(Entry::selectable)); + } + + fn git(repo: &Path, args: &[&str]) { + let out = run_git(repo, args).unwrap(); + assert!( + out.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); + } + + fn commit_file(repo: &Path, name: &str, msg: &str) { + std::fs::write(repo.join(name), format!("{name}\n")).unwrap(); + git(repo, &["add", name]); + git(repo, &["commit", "-qm", msg]); + } + + /// Against real repositories: a clone that has committed one change + /// and not pulled another lists both, its own arrowed `↑` and the + /// remote's `↓`, the remote branch named as one; a commit's files and + /// a file's diff read against its parent. + #[test] + fn read_log_lists_local_and_remote_commits_against_the_upstream() { + let dir = tempfile::tempdir().unwrap(); + let origin: PathBuf = dir.path().join("origin"); + let clone: PathBuf = dir.path().join("clone"); + std::fs::create_dir(&origin).unwrap(); + git(&origin, &["init", "-q", "-b", "main"]); + git(&origin, &["config", "user.email", "t@t"]); + git(&origin, &["config", "user.name", "t"]); + commit_file(&origin, "a", "first"); + let out = std::process::Command::new("git") + .args(["clone", "-q"]) + .arg(&origin) + .arg(&clone) + .output() + .unwrap(); + assert!(out.status.success()); + git(&clone, &["config", "user.email", "t@t"]); + git(&clone, &["config", "user.name", "t"]); + commit_file(&clone, "mine", "local work"); + commit_file(&origin, "theirs", "remote work"); + git(&clone, &["fetch", "-q"]); + + let read = read_log(&clone).unwrap(); + let by_subject = |s: &str| read.commits.iter().find(|c| c.subject == s).unwrap(); + assert_eq!(by_subject("local work").side, Side::Ahead); + assert_eq!(by_subject("remote work").side, Side::Behind); + assert_eq!(by_subject("first").side, Side::Shared); + assert_eq!(by_subject("local work").refs_label(), "(HEAD → main)"); + assert_eq!(by_subject("remote work").refs_label(), "(origin/main)"); + + let local = by_subject("local work"); + let files = read_commit_files(&clone, &local.sha).unwrap(); + assert_eq!(files.len(), 1); + assert_eq!(files[0].path, "mine"); + assert_eq!(files[0].xy, ['A', ' ']); + let diff = crate::git_diff::diff_for(&clone, &files[0], true, Some(&local.sha)); + assert!(diff.contains("+mine"), "{diff}"); + assert!(summary_for(&clone, &local.sha).contains("local work")); + assert!(summary_for(&clone, WORKING).contains("ahead 1, behind 1")); + } + + #[test] + fn read_log_on_an_unborn_head_lists_no_commits() { + let dir = tempfile::tempdir().unwrap(); + git(dir.path(), &["init", "-q"]); + let read = read_log(dir.path()).unwrap(); + assert!(read.commits.is_empty()); + } +} diff --git a/crates/nebula-tui/src/lib.rs b/crates/nebula-tui/src/lib.rs index f71b9edd..c7e2192f 100644 --- a/crates/nebula-tui/src/lib.rs +++ b/crates/nebula-tui/src/lib.rs @@ -14,6 +14,7 @@ pub mod event_loop; pub mod file_tabs; pub mod fuzzy; pub mod git_diff; +pub mod git_log; pub mod grep_search; pub mod hosts; pub mod ipc; diff --git a/crates/nebula-tui/src/ui.rs b/crates/nebula-tui/src/ui.rs index f49bff24..35d99026 100644 --- a/crates/nebula-tui/src/ui.rs +++ b/crates/nebula-tui/src/ui.rs @@ -1684,43 +1684,38 @@ fn draw_overlay(f: &mut Frame, app: &mut App) { } } Overlay::Diff(view) => { + use crate::app::Place; let area = centered_rect_pct(f.area(), SPLIT_MODAL_PCT.0, SPLIT_MODAL_PCT.1); f.render_widget(Clear, area); // Cap first, floor second: on a tiny screen the file list keeps // its minimum and SPLIT_PANE_LAYOUT_MIN squeezes the diff pane // instead. - let files_w = view - .files_width - .min(area.width.saturating_sub(crate::app::MIN_DIFF_PANE_W)) - .max(crate::app::MIN_DIFF_FILES_W); + let files_w = match view.files_width { + 0 => area.width / crate::app::DIFF_SIDEBAR_SHARE, + width => width, + } + .min(area.width.saturating_sub(crate::app::MIN_DIFF_PANE_W)) + .max(crate::app::MIN_DIFF_FILES_W); let [files_a, diff_a] = Layout::horizontal([ Constraint::Length(files_w), Constraint::Min(SPLIT_PANE_LAYOUT_MIN), ]) .areas(area); - // Left: changed-file list — flat paths, or the directory tree - // (`Ctrl+t`); a stateless follow-window keeps the selected row - // visible. - let mut files_title = if view.listing.is_some() && view.files.is_empty() { - "Files (…)".to_string() - } else if view.filter.is_empty() { - format!("Files ({})", view.files.len()) - } else { - format!("Files ({}/{})", view.matches.len(), view.files.len()) - }; - if !view.reviewed.is_empty() { - files_title.push_str(&format!(" · {}✓", view.reviewed.len())); - } + // Left: the SOURCE CONTROL sidebar, the CHANGES (flat paths, or + // the directory tree, `Ctrl+t`) over the GRAPH (`git_log`), + // each under a header that folds it; a stateless follow-window + // keeps the cursor's row visible. // The hint names the list `Ctrl+t` leads to, not the one up. - let block = panel_block(&files_title, true, th).title_bottom(Line::from(Span::styled( - if view.tree.is_some() { - " ^t: flat list " - } else { - " ^t: tree " - }, - Style::default().fg(th.dim), - ))); + let block = + panel_block("Source control", true, th).title_bottom(Line::from(Span::styled( + if view.tree.is_some() { + " ^t: flat list " + } else { + " ^t: tree " + }, + Style::default().fg(th.dim), + ))); let files_inner = block.inner(files_a); f.render_widget(block, files_a); @@ -1730,148 +1725,114 @@ fn draw_overlay(f: &mut Frame, app: &mut App) { f.render_widget(Paragraph::new(line), filter_area); } let list_inner = below_first_row(files_inner); - - if view.listing.is_some() && view.files.is_empty() { - empty_list_row(f, list_inner, "reading changes…", th); - } else if view.row_count() == 0 { - empty_list_row(f, list_inner, NO_MATCHES, th); - } let start = view.window_start(list_inner.height as usize); - // Both lists open a row the same way: the status code, then the - // ✓ — so the two columns read straight down whichever is up. - let gutter = |file: Option<&crate::git_diff::DiffFile>, reviewed: bool| { - let status = match file { - Some(file) => Span::styled( - format!("{} ", file.status_str()), - Style::default().fg(match (file.xy[0], file.xy[1]) { - ('?', '?') | ('A', _) => th.ok, - ('D', _) | (_, 'D') => th.err, - ('R', _) | ('C', _) => th.accent, - _ => th.warn, - }), - ), - None => Span::raw(" "), + let cursor = view.side_cursor(); + let done = view + .tree + .as_ref() + .map(|tree| tree.reviewed_nodes(&view.files, &view.reviewed)); + let now = crate::app::now_ms(); + for (row, index) in (start..view.side_len()).enumerate() { + let Some(row_area) = row_rect(list_inner, row) else { + break; }; - let mark = if reviewed { - Span::styled("✓ ", Style::default().fg(th.ok)) - } else { - Span::raw(" ") + let Some((place, i)) = view.side_row(index) else { + break; }; - vec![status, mark] - }; - match &view.tree { - None => { - for (row, (i, m)) in view.matches.iter().enumerate().skip(start).enumerate() { - let Some(row_area) = row_rect(list_inner, row) else { - break; - }; - let file = &view.files[m.file]; - let budget = (list_inner.width as usize).saturating_sub(5); - let mut spans = gutter(Some(file), view.reviewed.contains_key(&file.path)); - let shown = truncate(&file.path, budget); - let used = shown.chars().count(); - spans.extend(fuzzy_highlight_spans(&shown, &m.positions, th)); - if let Some(orig) = &file.orig_path { - let rest = budget.saturating_sub(used); - if rest > 3 { - spans.push(Span::styled( - truncate(&format!(" ← {orig}"), rest), - Style::default().fg(th.dim), - )); - } - } - render_row(f, row_area, spans, i == view.selected, true, th); - } - } - // The TREE BROWSER's rows behind the flat list's gutter: a - // directory wears the fold marker and the accent, and its ✓ - // once every file under it has one. - Some(tree) => { - let done = tree.reviewed_nodes(&view.files, &view.reviewed); - for (row, (i, r)) in tree.rows.iter().enumerate().skip(start).enumerate() { - let Some(row_area) = row_rect(list_inner, row) else { - break; - }; - let node = &tree.nodes[r.node]; - let file = tree.file_of[r.node].map(|f| &view.files[f]); - let indent = " ".repeat(node.depth); - let marker = if !node.is_dir { - " " - } else if tree.is_open(r.node, !view.filter.is_empty()) { - "▾ " - } else { - "▸ " - }; - let budget = (list_inner.width as usize) - .saturating_sub(5 + indent.chars().count() + 2); - let shown = truncate(&node.name, budget); - let mut spans = gutter(file, done[r.node]); - spans.push(Span::raw(indent)); - spans.push(Span::styled(marker, Style::default().fg(th.accent))); - if node.is_dir { - spans.push(Span::styled(shown, Style::default().fg(th.accent))); - } else { - let positions = visible_positions(&r.positions, &shown, &node.name); - spans.extend(fuzzy_highlight_spans(&shown, positions, th)); - } - render_row(f, row_area, spans, i == tree.selected, true, th); + let mut spans = match place { + Place::ChangesHeader => changes_header_spans(&view, th), + Place::GraphHeader => graph_header_spans(&view, th), + _ => vec![Span::raw(" ")], + }; + match (place, &view.log) { + (Place::Changes, _) => { + let width = list_inner.width.saturating_sub(2); + spans.extend(change_row_spans(&view, i, done.as_deref(), width, th)); } + (Place::Graph, Some(log)) => spans.extend(log_row_spans(log, i, now, th)), + _ => {} } + render_row(f, row_area, spans, index == cursor, true, th); } - // Right: the selected file's diff, scrolled — or, on a tree - // directory's row, the list of what changed under it. - let sel_path = match view.selected_dir() { - Some(dir) => format!("{dir}/"), - None => view.selected_path().unwrap_or("").to_string(), + // Right: what the cursor is on: a changed file's diff (a tree + // directory's list of what changed under it), a commit, one of + // its files, or on a header the checkout's `git status`. + let title = match (view.place, view.log.as_ref()) { + (Place::Changes, _) => { + let sel_path = match view.selected_dir() { + Some(dir) => format!("{dir}/"), + None => view.selected_path().unwrap_or("").to_string(), + }; + let sel_reviewed = view.reviewed.contains_key(&sel_path); + let mark = if sel_reviewed { " ✓" } else { "" }; + format!("{}: {sel_path}{mark}", view.branch) + } + (Place::Graph, Some(log)) => match log.selected_entry() { + Some(crate::git_log::Entry::File(c, file)) => format!( + "{}: {}", + log.commits[c].short, + log.file_at(c, file).map_or("", |f| f.path.as_str()) + ), + _ => log.selected_commit().map_or(view.branch.clone(), |c| { + format!("{} {}", c.short, c.subject) + }), + }, + _ => format!("{}: status", view.branch), }; - let sel_reviewed = view.reviewed.contains_key(&sel_path); - let title = truncate( - &format!( - "{}: {}{}", - view.branch, - sel_path, - if sel_reviewed { " ✓" } else { "" } - ), - (diff_a.width as usize).saturating_sub(4), - ); - let mut block = panel_block(&title, true, th).title_bottom(Line::from(Span::styled( - " ^r: toggle reviewed ", + let title = truncate(&title, (diff_a.width as usize).saturating_sub(4)); + let mut block = panel_block(&title, true, th); + if view.place == Place::Changes && view.prefetched.is_none() { + block = block.title_bottom(Line::from(Span::styled( + " ^r: toggle reviewed ", + Style::default().fg(th.dim), + ))); + } + block = block.title_bottom(Line::from(Span::styled( + if view.split { + " ^s: unified " + } else { + " ^s: side by side " + }, Style::default().fg(th.dim), ))); let diff_inner = block.inner(diff_a); - let max_scroll = (view.diff_line_count as u16).saturating_sub(diff_inner.height.max(1)); + // Side by side when it fits; a narrow pane reads the unified + // diff instead. + let split = view + .split_rows + .as_deref() + .filter(|_| diff_inner.width >= crate::app::MIN_SPLIT_W); + let total = split.map_or(view.diff_line_count, <[_]>::len); + let max_scroll = (total as u16).saturating_sub(diff_inner.height.max(1)); let scroll = view.scroll.min(max_scroll); if max_scroll > 0 { block = block.title_bottom( Line::from(Span::styled( - format!(" {}/{} ", scroll + 1, view.diff_line_count), + format!(" {}/{} ", scroll + 1, total), Style::default().fg(th.dim), )) .right_aligned(), ); } f.render_widget(block, diff_a); - // Only the rows in view are styled: a diff runs to 20 000 - // lines, and building a `Line` for each of them on every frame - // was most of what scrolling a large one cost. - let lines: Vec = view - .diff - .lines() - .skip(scroll as usize) - .take(diff_inner.height as usize) - .map(|l| { - let style = match classify_diff_line(l) { - DiffLineKind::Add => Style::default().fg(th.ok), - DiffLineKind::Remove => Style::default().fg(th.err), - DiffLineKind::Hunk => Style::default().fg(th.accent), - DiffLineKind::Header => Style::default().fg(th.dim), - DiffLineKind::Context => Style::default(), - }; - Line::from(Span::styled(l.to_string(), style)) - }) - .collect(); - f.render_widget(Paragraph::new(lines), diff_inner); + match split { + Some(rows) => draw_split_diff(f, rows, scroll, diff_inner, th), + None => { + // Only the rows in view are styled: a diff runs to + // 20 000 lines, and building a `Line` for each of them + // on every frame was most of what scrolling a large one + // cost. + let lines: Vec = view + .diff + .lines() + .skip(scroll as usize) + .take(diff_inner.height as usize) + .map(|l| Line::from(Span::styled(l.to_string(), diff_line_style(l, th)))) + .collect(); + f.render_widget(Paragraph::new(lines), diff_inner); + } + } // Write-back (draw works on a clone): page size for key paging, // scroll re-clamped so resizes never strand the view. @@ -1881,6 +1842,7 @@ fn draw_overlay(f: &mut Frame, app: &mut App) { v.list_area = list_inner; v.area = area; v.files_width = files_w; + v.split_shown = split.is_some(); } } Overlay::Palette(palette) => { @@ -2848,6 +2810,350 @@ fn render_modal_frame<'a>( /// A dim one-line placeholder on the first row of an otherwise empty list, /// when the list has a first row at all. +/// A unified diff line's color: added green, removed red, hunk headers in +/// the accent, file headers dimmed. +fn diff_line_style(line: &str, th: Theme) -> Style { + match classify_diff_line(line) { + DiffLineKind::Add => Style::default().fg(th.ok), + DiffLineKind::Remove => Style::default().fg(th.err), + DiffLineKind::Hunk => Style::default().fg(th.accent), + DiffLineKind::Header => Style::default().fg(th.dim), + DiffLineKind::Context => Style::default(), + } +} + +/// A diff side by side (`git_diff::split_rows`): the old file left and the +/// new one right, each line numbered, a removed line facing what replaced +/// it. Only the rows in view are built. +fn draw_split_diff( + f: &mut Frame, + rows: &[crate::git_diff::SplitRow], + scroll: u16, + area: Rect, + th: Theme, +) { + use crate::git_diff::SplitRow; + let widest = rows + .iter() + .filter_map(|row| match row { + SplitRow::Pair { left, right, .. } => Some( + left.as_ref() + .map_or(0, |l| l.0) + .max(right.as_ref().map_or(0, |r| r.0)), + ), + SplitRow::Note(..) => None, + }) + .max() + .unwrap_or(0); + let num_w = widest.to_string().len(); + let [left_a, sep_a, right_a] = Layout::horizontal([ + Constraint::Length(area.width.saturating_sub(1) / 2), + Constraint::Length(1), + Constraint::Min(0), + ]) + .areas(area); + let dim = Style::default().fg(th.dim); + let cell = |side: &Option<(u32, String)>, style: Style| match side { + Some((n, text)) => Line::from(vec![ + Span::styled(format!("{n:>num_w$} "), dim), + Span::styled(text.replace('\t', " "), style), + ]), + None => Line::default(), + }; + let (mut left, mut right) = (Vec::new(), Vec::new()); + for row in rows.iter().skip(scroll as usize).take(area.height as usize) { + match row { + SplitRow::Note(text, kind) => { + let style = if *kind == DiffLineKind::Hunk { + Style::default().fg(th.accent) + } else { + dim + }; + left.push(Line::from(Span::styled(text.clone(), style))); + right.push(Line::default()); + } + SplitRow::Pair { + left: old, + right: new, + changed, + } => { + let (old_style, new_style) = if *changed { + (Style::default().fg(th.err), Style::default().fg(th.ok)) + } else { + (Style::default(), Style::default()) + }; + left.push(cell(old, old_style)); + right.push(cell(new, new_style)); + } + } + } + let seam = vec![Line::from(Span::styled("│", dim)); area.height as usize]; + f.render_widget(Paragraph::new(left), left_a); + f.render_widget(Paragraph::new(seam), sep_a); + f.render_widget(Paragraph::new(right), right_a); +} + +/// A section header's fold marker and name, bold. +fn header_spans(open: bool, name: &str, th: Theme) -> Vec> { + vec![ + Span::styled( + if open { "▾ " } else { "▸ " }, + Style::default().fg(th.accent), + ), + Span::styled( + name.to_string(), + Style::default().add_modifier(Modifier::BOLD), + ), + ] +} + +/// `▾ CHANGES (3) · 1✓`: the changed files, how many the filter kept, how +/// many are reviewed. A pull request's view lists its FILES. +fn changes_header_spans(view: &crate::app::DiffView, th: Theme) -> Vec> { + let name = if view.prefetched.is_some() { + "FILES" + } else { + "CHANGES" + }; + let mut spans = header_spans(view.changes_open, name, th); + let count = if view.listing.is_some() && view.files.is_empty() { + " (…)".to_string() + } else if view.filter.is_empty() { + format!(" ({})", view.files.len()) + } else { + format!(" ({}/{})", view.matches.len(), view.files.len()) + }; + spans.push(Span::styled(count, Style::default().fg(th.dim))); + if !view.reviewed.is_empty() { + spans.push(Span::styled( + format!(" · {}✓", view.reviewed.len()), + Style::default().fg(th.dim), + )); + } + spans +} + +/// `▾ GRAPH (40) ↑2 ↓1`: the commits listed, and how many HEAD's upstream +/// lacks (a push would send) and HEAD lacks (a pull would bring). +fn graph_header_spans(view: &crate::app::DiffView, th: Theme) -> Vec> { + let mut spans = header_spans(view.graph_open, "GRAPH", th); + let Some(log) = &view.log else { + return spans; + }; + let commits = log + .rows + .iter() + .filter(|r| matches!(r.entry, crate::git_log::Entry::Commit(_))); + let count = if log.reading.is_some() && log.commits.is_empty() { + " (…)".to_string() + } else if view.filter.is_empty() { + format!(" ({})", log.commits.len()) + } else { + format!(" ({}/{})", commits.count(), log.commits.len()) + }; + spans.push(Span::styled(count, Style::default().fg(th.dim))); + let (ahead, behind) = log.sides(); + if ahead > 0 { + spans.push(Span::styled( + format!(" ↑{ahead}"), + Style::default().fg(th.ok), + )); + } + if behind > 0 { + spans.push(Span::styled( + format!(" ↓{behind}"), + Style::default().fg(th.warn), + )); + } + spans +} + +/// A changed file's status code and reviewed ✓, the two columns both +/// lists of the CHANGES open a row with. +fn change_gutter( + file: Option<&crate::git_diff::DiffFile>, + reviewed: bool, + th: Theme, +) -> Vec> { + let status = match file { + Some(file) => Span::styled( + format!("{} ", file.status_str()), + Style::default().fg(match (file.xy[0], file.xy[1]) { + ('?', '?') | ('A', _) => th.ok, + ('D', _) | (_, 'D') => th.err, + ('R', _) | ('C', _) => th.accent, + _ => th.warn, + }), + ), + None => Span::raw(" "), + }; + let mark = if reviewed { + Span::styled("✓ ", Style::default().fg(th.ok)) + } else { + Span::raw(" ") + }; + vec![status, mark] +} + +/// Row `i` of the CHANGES: a flat path, or a row of the directory tree (a +/// directory wears the fold marker and the accent, and its ✓ once every +/// file under it has one). +fn change_row_spans( + view: &crate::app::DiffView, + i: usize, + done: Option<&[bool]>, + width: u16, + th: Theme, +) -> Vec> { + let Some(tree) = &view.tree else { + let Some(m) = view.matches.get(i) else { + return Vec::new(); + }; + let file = &view.files[m.file]; + let budget = (width as usize).saturating_sub(5); + let mut spans = change_gutter(Some(file), view.reviewed.contains_key(&file.path), th); + let shown = truncate(&file.path, budget); + let used = shown.chars().count(); + spans.extend(fuzzy_highlight_spans(&shown, &m.positions, th)); + if let Some(orig) = &file.orig_path { + let rest = budget.saturating_sub(used); + if rest > 3 { + spans.push(Span::styled( + truncate(&format!(" ← {orig}"), rest), + Style::default().fg(th.dim), + )); + } + } + return spans; + }; + let Some(r) = tree.rows.get(i) else { + return Vec::new(); + }; + let node = &tree.nodes[r.node]; + let file = tree.file_of[r.node].map(|f| &view.files[f]); + let indent = " ".repeat(node.depth); + let marker = if !node.is_dir { + " " + } else if tree.is_open(r.node, !view.filter.is_empty()) { + "▾ " + } else { + "▸ " + }; + let budget = (width as usize).saturating_sub(5 + indent.chars().count() + 2); + let shown = truncate(&node.name, budget); + let reviewed = done.is_some_and(|d| d[r.node]); + let mut spans = change_gutter(file, reviewed, th); + spans.push(Span::raw(indent)); + spans.push(Span::styled(marker, Style::default().fg(th.accent))); + if node.is_dir { + spans.push(Span::styled(shown, Style::default().fg(th.accent))); + } else { + let positions = visible_positions(&r.positions, &shown, &node.name); + spans.extend(fuzzy_highlight_spans(&shown, positions, th)); + } + spans +} + +/// `git log --graph`'s ASCII drawn with box lines, each lane in a color of +/// its own and each commit a dot on its lane. Under an unfolded commit its +/// files carry the lanes on, the commit's dot a line. +fn graph_spans(graph: &str, under: bool, th: Theme) -> Vec> { + let lanes = [th.accent, th.ok, th.warn, th.special, th.merged, th.err]; + graph + .chars() + .enumerate() + .map(|(col, ch)| { + let glyph = match ch { + '*' if under => '│', + '*' => '●', + '|' => '│', + '/' => '╱', + '\\' => '╲', + '-' | '_' => '─', + other => other, + }; + Span::styled( + glyph.to_string(), + Style::default().fg(lanes[col / 2 % lanes.len()]), + ) + }) + .collect() +} + +/// Row `i` of the GRAPH: the graph, then a commit's arrow against HEAD's +/// upstream (`↑` to push, `↓` to pull), its hash, refs and subject (the +/// filter's matches lit, while it narrows the list) and who and when, +/// dimmed; or under an unfolded commit, one of its files. +fn log_row_spans( + log: &crate::git_log::GitLog, + i: usize, + now: i64, + th: Theme, +) -> Vec> { + use crate::git_log::{Entry, RefKind, Side}; + let Some(r) = log.rows.get(i) else { + return Vec::new(); + }; + let dim = Style::default().fg(th.dim); + let graph = r.line.map_or("", |n| log.lines[n].0.as_str()); + let under = matches!(r.entry, Entry::File(..) | Entry::Note(_)); + let mut spans = graph_spans(graph, under, th); + if !graph.is_empty() { + spans.push(Span::raw(" ")); + } + match r.entry { + Entry::Graph => {} + Entry::Note(c) => spans.push(Span::styled(format!(" {}", log.note(c)), dim)), + Entry::File(c, f) => { + if let Some(file) = log.file_at(c, f) { + spans.push(Span::raw(" ")); + spans.extend(change_gutter(Some(file), false, th).into_iter().take(1)); + spans.push(Span::raw(file.path.clone())); + if let Some(orig) = &file.orig_path { + spans.push(Span::styled(format!(" ← {orig}"), dim)); + } + } + } + Entry::Commit(c) => { + let c = &log.commits[c]; + match c.side { + Side::Ahead => spans.push(Span::styled("↑ ", Style::default().fg(th.ok))), + Side::Behind => spans.push(Span::styled("↓ ", Style::default().fg(th.warn))), + Side::Shared => {} + } + if r.positions.is_empty() { + spans.push(Span::styled(c.short.clone(), Style::default().fg(th.muted))); + if !c.refs.is_empty() { + spans.push(Span::raw(" (")); + for (n, rf) in c.refs.iter().enumerate() { + if n > 0 { + spans.push(Span::raw(", ")); + } + let style = Style::default().fg(match rf.kind { + RefKind::Head | RefKind::Local => th.ok, + RefKind::Remote => th.accent, + RefKind::Tag => th.warn, + }); + let style = if rf.kind == RefKind::Head { + style.add_modifier(Modifier::BOLD) + } else { + style + }; + spans.push(Span::styled(rf.label(), style)); + } + spans.push(Span::raw(")")); + } + spans.push(Span::raw(format!(" {}", c.subject))); + } else { + spans.extend(fuzzy_highlight_spans(&c.haystack(), &r.positions, th)); + } + let ago = crate::hosts::ago_label(now - c.time * 1000); + spans.push(Span::styled(format!(" {} · {ago}", c.author), dim)); + } + } + spans +} + pub(crate) fn empty_list_row(f: &mut Frame, list_inner: Rect, text: &str, th: Theme) { if let Some(row_area) = row_rect(list_inner, 0) { f.render_widget( @@ -3869,13 +4175,9 @@ fn draw_footer_bar(f: &mut Frame, app: &mut App, area: Rect) { ), Style::default().fg(th.dim), ) - } else if let Some(Overlay::Diff(view)) = &app.overlay { + } else if let Some(Overlay::Diff(_)) = &app.overlay { Span::styled( - if view.tree.is_some() { - "type: filter ↑/↓: move ←/→: fold ⇧↑/↓: scroll Ctrl+d/u: half list Ctrl+t: flat list Ctrl+u: clear filter Esc: clear/close" - } else { - "type: filter ↑/↓: file ⇧↑/↓: scroll Ctrl+d/u: half list Ctrl+t: tree Ctrl+u: clear filter Esc: clear/close" - }, + "type: filter ↑/↓: move Enter/→/←: unfold/fold ⇧↑/↓: scroll Ctrl+d/u: half list Ctrl+s: split/unified Ctrl+t: tree Ctrl+r: reviewed Esc: clear/close", Style::default().fg(th.dim), ) } else if let Some(Overlay::FileTabs(view)) = &app.overlay { diff --git a/crates/nebula-tui/src/view_jobs.rs b/crates/nebula-tui/src/view_jobs.rs index d0ce18d2..9602901c 100644 --- a/crates/nebula-tui/src/view_jobs.rs +++ b/crates/nebula-tui/src/view_jobs.rs @@ -1,5 +1,6 @@ -//! BACKGROUND READS for the worktree views — the DIFF VIEWER (`g`), the FILE -//! FINDER (`f`), its grep view (`F`) and the TREE BROWSER (`b`). +//! BACKGROUND READS for the worktree views — the DIFF VIEWER (`g`) and its +//! GRAPH, the FILE FINDER (`f`), its grep view (`F`) and the TREE BROWSER +//! (`b`). //! //! Every one of them is git and the disk: `git status -uall` to list what //! changed, `git ls-files` for the finder and the tree, a `git diff` per @@ -83,6 +84,18 @@ pub enum Answer { ticket: u64, preview: Box, }, + /// The DIFF VIEWER's GRAPH (`git_log::read_log`). + Log { + ticket: u64, + result: Result, + }, + /// The files of a commit unfolded in the GRAPH. + CommitFiles { + /// The `DiffView::id` that asked. + view: u64, + sha: String, + result: Result, String>, + }, /// [`STALE_GRACE`] is up on `ticket`. Slow { ticket: u64 }, /// The system clipboard could not be written off the loop: hand the diff --git a/crates/nebula/tests/e2e_tui.rs b/crates/nebula/tests/e2e_tui.rs index 8b6dc18c..0dd24bb7 100644 --- a/crates/nebula/tests/e2e_tui.rs +++ b/crates/nebula/tests/e2e_tui.rs @@ -699,7 +699,7 @@ fn tui_git_diff_modal() { // ---- open the modal; the selected file's diff renders ---- tui.send(b"g"); - tui.wait_for_text("Files (2)"); + tui.wait_for_text("CHANGES (2)"); // Status is path-ordered, so .keep (modified) is selected first. tui.wait_for_selected(".keep"); tui.wait_for_text("+tracked change"); @@ -726,33 +726,39 @@ fn tui_git_diff_modal() { // ---- type-to-filter narrows the list and reselects the top match ---- tui.type_str("kee"); - tui.wait_for_text("Files (1/2)"); + tui.wait_for_text("CHANGES (1/2)"); tui.wait_for_selected(".keep"); tui.wait_for_text("+tracked change"); tui.send(ESC); // first clears the filter, not the modal - tui.wait_for_text("Files (2)"); + tui.wait_for_text("CHANGES (2)"); // ---- the modal blocks other interaction ---- // n would open the NEW SESSION PICKER on the grid; inside the modal it // feeds the filter instead (verified after close — stale-frame // convention). tui.send(b"n"); - tui.wait_for_text("no matches"); + tui.wait_for_text("CHANGES (0/2)"); tui.send(ESC); // clears the filter… - tui.wait_for_text("Files (2)"); // (also keeps the two Escs from coalescing) + tui.wait_for_text("CHANGES (2)"); // (also keeps the two Escs from coalescing) tui.send(ESC); // …and the second closes the modal - tui.wait_for_gone("Files (2)"); + tui.wait_for_gone("CHANGES (2)"); assert!( !tui.screen_text().contains("what should the agent do?"), "modal swallowed n\n--- screen ---\n{}", tui.screen_text() ); - // ---- clean tree flashes instead of opening ---- + // ---- a clean tree opens on HEAD's commit in the graph ---- repo_git(&repo, &["add", "."]); repo_git(&repo, &["commit", "-m", "wip"]); tui.send(b"g"); - tui.wait_for_text("no changes in main"); + tui.wait_for_text("CHANGES (0)"); + tui.wait_for_text("(HEAD → main) wip"); + // Enter unfolds the commit into its files, right under it. + tui.send(b"\r"); + tui.wait_for_text("A hello.txt"); + tui.send(ESC); + tui.wait_for_gone("GRAPH ("); } /// The BRANCH SWITCHER end to end: `c` lists the repo's branches, typing diff --git a/docs/keys.md b/docs/keys.md index 761cc398..446910a1 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -11,7 +11,7 @@ The grid isn't the only view. With a session selected: | Key | View | |---|---| -| **`g`** | **Git diff.** Changed files down the left, the diff on the right, with a live fuzzy filter. With the pane reading a pull request (after a `/` jump onto one) it shows that pull request's diff instead, fetched whole with `gh pr diff`. `Ctrl+r` marks a file reviewed ✓ and sinks it to the bottom — nebula-side bookkeeping only, no git state is touched — and every mark clears itself when HEAD moves or the file changes again, so what's left unticked is genuinely what you haven't read. `Ctrl+t` folds the file list into a directory tree and back — every directory open, a chain of directories that hold only each other shown as one row (`crates/nebula-tui/src`) — and the choice is remembered; in the tree `→`/`←` open and fold a directory, `Enter` or a click flips it, a directory's row lists what changed under it, and a directory wears the ✓ once every file under it has one. | +| **`g`** | **Git diff.** Changed files down the left, the diff on the right, with a live fuzzy filter. With the pane reading a pull request (after a `/` jump onto one) it shows that pull request's diff instead, fetched whole with `gh pr diff`. `Ctrl+r` marks a file reviewed ✓ and sinks it to the bottom — nebula-side bookkeeping only, no git state is touched — and every mark clears itself when HEAD moves or the file changes again, so what's left unticked is genuinely what you haven't read. `Ctrl+t` folds the file list into a directory tree and back — every directory open, a chain of directories that hold only each other shown as one row (`crates/nebula-tui/src`) — and the choice is remembered; in the tree `→`/`←` open and fold a directory, `Enter` or a click flips it, a directory's row lists what changed under it, and a directory wears the ✓ once every file under it has one. The left side is a SOURCE CONTROL sidebar, as in VS Code: the changed files under a **CHANGES** header, and under a **GRAPH** header the checkout's newest 500 commits across HEAD, every local branch and every remote-tracking one, drawn as `git log --graph` draws them with each lane in its own color, each commit with its refs, author and age. `↑` marks a commit HEAD's upstream does not have yet (a push would send it), `↓` one HEAD does not have yet (a pull would bring it), and the GRAPH header counts both. `↑`/`↓` walk straight from the files into the commits; the right pane reads whatever the cursor is on (a commit's message and stat, a header's `git status`). `Enter`, `→` or a click unfolds a commit into its files right under it, each diffed against the commit's first parent; `←` or `Enter` folds it, and the same keys fold a whole section on its header. Typing filters both sections at once (commits by hash, ref or subject). Diffs show side by side, old left and new right with line numbers, and `Ctrl+s` flips to the unified diff and back (remembered; a pane narrower than 90 columns shows the unified one either way). A checkout with nothing changed opens on HEAD's commit. Reading only: nothing in the sidebar writes to the repository. | | **`f`** | **Find file.** Fuzzy finder over the worktree. `Enter` opens the file in an editor modal (vim by default; the `editor` setting or `NEBULA_EDITOR` picks another) — except a markdown file, which is read first: it opens as the rendered page in the file tabs below, where `Enter` is the editor and `m` shows the source — and `Ctrl+y` copies the path — ready to paste into an agent. The editor modal sits above every OVERLAY and is handed every key raw — vim owns `Esc` — so `Ctrl+q` is the only way out of it: one press kills the editor process and closes the modal, and the mouse is inert the whole time it is up. | | **`F`** | **Find in files.** `git grep` into the same modal; `Enter` opens the hit at its line. | | **`i`** | **GitHub issues.** The selected project's open issues, newest first down the left, the one under the cursor read on the right — number, title, author, labels, the description, and its comments once the cursor has rested on it for a moment (the `gh issue list` runs in the background once the cursor rests on the project, and again every couple of minutes while it stays selected, so the modal opens on rows already there; one `gh issue view` per issue you stop on). The list filters as you type, from the moment the modal is up: letters narrow the rows to the fuzzy matches of `#15 title`, best first with the cursor on the best and the matched letters lit (the count reads `2/14`), `↑`/`↓` (or `Ctrl+n`/`Ctrl+p`) walk the matches, and `Esc` clears the filter before a second `Esc` closes. So the verbs are chords, as in the diff viewer. `PgUp`/`PgDn`, `Ctrl+d`/`Ctrl+u` and `Shift+↑`/`Shift+↓` read (`Ctrl+u` kills the typed filter first, as in any line editor), `Ctrl+o` — or a click on the `↗ open in browser` button pinned right on the reading pane's frame — opens the issue in the browser, the footer saying where it went; `Ctrl+r` asks GitHub again. `Ctrl+c` (or `Ctrl+y`, the grid's `y` as a chord) leaves a comment on the issue: a multi-row box (`Shift+Enter` for a newline) whose `Enter` posts the text as you with `gh issue comment` and lands back on the row, the pane saying the comment is on its way until GitHub answers and the conversation is read again; `Esc`, or an empty box, lands back without posting, and a post `gh` refused brings the box back with your text. `Ctrl+e` edits the issue itself, in place: the reading pane becomes a form on its title and description (`Tab`, `↑`/`↓` or a click move between the two, `Shift+Enter` breaks a line in the description), `Enter` sends both to GitHub as one `gh issue edit` and puts the pane back reading the new text, `Esc` drops the draft; a save GitHub refuses keeps the form up with `gh`'s reason on it and your text intact. `Enter` opens the QUICK PROMPT *for that issue* and `Shift+Tab` picks one of your AGENT PRESETS for it — the box's own keys, and the same box either way, titled `Quick prompt · issue #15 …`, with `Tab` / `Shift+Tab` / `Ctrl+N` working as they do in any quick prompt. What that launch starts is an ISSUE SESSION: the issue's URL travels with the create and reaches the harness as context on every spawn and resume — Claude and Pi in the appended system prompt, Grok Build in its `--rules`, Codex, Cursor, Muse and OpenCode at the top of their first prompt — so the agent knows which issue the session is for before it reads a word of your task. Send the box empty and the task is `Fix GitHub issue #15: `; it launches on the project's root branch, and `Ctrl+N` inside it cuts a fresh worktree named `issue-15-<title-slug>` instead (the box starts on that fresh worktree with `New worktree` on under **Quick prompt** in Settings → Agents). | @@ -45,7 +45,7 @@ The grid isn't the only view. With a session selected: | The grid (a card) | `e` | agent presets: saved launch definitions (harness, model, effort, text on the side of the task the form's **Text** row names — a prefix, a postfix or both; a new preset starts on `prefix` alone unless **Preset text** in Settings → Sessions says otherwise — and whether to ask for a task). The launch lands in the checkout under the cursor — or, with `Tab` (or a click on the list's bottom `[ ] new worktree` row), in a fresh worktree of the project cut first, the row turning into a green NEW WORKTREE chip naming the branch; `Tab` again flips back. The same toggle is on the list wherever it opens: in the ISSUES MODAL it flips between the root checkout and a fresh `issue-15-<title-slug>` worktree, and a PR SESSION's row just names the pull request's own checkout, there being nothing to flip. `Enter` asks for an optional task and starts the agent with prefix + task + postfix as its first prompt — sent empty, the prefix and postfix go alone — or, for a preset whose **Task** row is `skip`, launches at once without asking. Type to find a preset by name — letters narrow the rows to the fuzzy matches, the cursor on the best, the query in the title (`Agent presets ⌕ rev`); `↑`/`↓` (or `Ctrl+n`/`Ctrl+p`) move, `Backspace` widens, and `Esc` clears the letters before it closes. Because letters type, the verbs are chords: `Ctrl+a` / `Ctrl+e` / `Ctrl+d` create, edit, delete (`Delete` deletes too). In the PULL REQUESTS MODAL, `Shift+Tab` on a pull request is the same picker, launching the preset as a PR SESSION on it | | The grid | `p` | quick prompt: a wrapped, multi-row task box (`Shift+Enter`, `Option+Enter` or `Ctrl+J` inserts a line and `↑`/`↓` walk the rows as they wrap, as in Claude Code's own prompt — the keys for getting around a long prompt are under *Any multi-row box* below; `Esc` closes the box, `Enter` launches) that starts a new agent in the checkout under the cursor — the worktree whose band the cursor is on — with what you typed as its first prompt — no picker. Which CLI it launches is the `Agent` row under **Quick prompt** in Settings → Agents, run with that harness's own default model and effort; the session titles itself from the prompt. For one launch only, `Tab` picks a different harness (`→` drills into its model and effort, same as the new-session picker) and `Shift+Tab` picks one of your saved agent presets — adopting its harness, model, effort and prefix/postfix wrapping; sent empty with a preset on, the box launches on the prefix and postfix alone, and a `skip`-task preset picked over an empty box launches right away. Either picker hands the box back with your text intact, on `Esc` too. The launch lands the cursor on the new session's card as it goes up in its band, the grid scrolling to it (shown in the pane, the keys still on the grid) — turn off `Follow new` under **Quick prompt** in Settings → Agents to stay out of its way instead, the cursor and the pane staying on the card you were on and the footer naming the branch it went to, or turn on `Focus` there to drop straight into its terminal. The box launches in the checkout under the cursor by default — the worktree whose band the cursor is on, open or collapsed, the new session starting beside the ones already running there (more work in one session's own conversation is its follow-up, `Space`) — and on the project's root branch once `Esc` has let the aim go; turn on `New worktree` under **Quick prompt** in Settings → Agents to have every new box cut a fresh worktree first — a random branch off the fetched `origin/HEAD`, or the **Worktree base branch** setting — its band going up beside the rest, the cursor moving onto it unless `Follow new` is off. `Ctrl+N` inside the box flips between the two for this one launch only — the next box starts from the setting again: the row under the title says which — a quiet `worktree: main` with `[ ] new worktree` unticked, or a filled `NEW WORKTREE` chip naming the branch to be cut with `[✓] new worktree` ticked and the whole frame turned green — and the text and caret stay put across the flip. `Enter` on the box empty starts the session all the same — the harness, model and effort the title names, with no first prompt: you type it in the CLI (a Claude Cloud box, which needs its task, closes instead). Closing the box does not throw the prompt away: `Esc`, a click outside it and `^q` park what you typed, and the next `p` opens on it — aimed the same way, the box comes back whole, the `Tab` harness or `Shift+Tab` preset you had picked included; aimed somewhere else, your text comes back into that box's own launch. One draft is kept, in memory only, and the box that takes it back is the last place it can be lost from: clear it there and close it and it is gone | | The grid | `Shift+D` | delete every session listed for the checkout under the cursor (the confirm lists the casualties) | -| The grid | `g` | git diff for the selected worktree: filter, `↑↓` files, `Ctrl+d/u` half the file list down / up (flat or tree, as in vim), `Shift+↑↓`/`PgUp/PgDn`/`Home`/`End` scroll the diff, the wheel walks the files over the list and scrolls the diff over the diff, `Ctrl+r` marks a file reviewed ✓, `Ctrl+t` flips the file list between flat paths and a directory tree (`←`/`→` fold, `Enter` flips a directory). `Ctrl+u` moves only while the filter is empty — with something typed it is the LINE EDITOR's kill-to-start | +| The grid | `g` | git diff for the selected worktree: filter, `↑↓` files, `Ctrl+d/u` half the file list down / up (flat or tree, as in vim), `Shift+↑↓`/`PgUp/PgDn`/`Home`/`End` scroll the diff, the wheel walks the files over the list and scrolls the diff over the diff, `Ctrl+r` marks a file reviewed ✓, `Ctrl+t` flips the file list between flat paths and a directory tree (`←`/`→` fold, `Enter` flips a directory), the GRAPH below the changes lists the commits of HEAD and every local and remote branch (`Enter`/`→`/click unfold one into its files, `←` folds), `Ctrl+s` flips side by side and unified diffs. `Ctrl+u` moves only while the filter is empty — with something typed it is the LINE EDITOR's kill-to-start | | The grid | `Shift+G` | open the selected repo's page on its git host — the `origin` remote (`git@github.com:o/r.git`, `ssh://`, `https://`) turned into a browsable URL, credentials stripped | | The grid (a card) | `Shift+V` | open the card's pull request in the browser — the `#42 title` line under its name, the pull request on the branch its session runs in — without stepping into the session or opening the PULL REQUESTS MODAL. `v` lists the project's pull requests in nebula; `Shift+V` goes to GitHub. It is marked read on the way out, as when it opens from anywhere else. A card with no pull request yet says so and names its branch (`Shift+R` asks GitHub again); with no card selected (after `Esc`) nothing opens. A click on that `#42 title` line, on any card, does the same — the cursor lands on the card first — and the line is underlined while the pointer rests on it, the way the header's tabs are. The card's right-click menu has it as **Open pull request**; rebindable as `open_pull_request` | | The grid (a card) | `Shift+I` | open the GitHub issue the card's session was started from — an ISSUE SESSION, launched out of the ISSUES MODAL — in the browser. `i` lists the project's issues in nebula; `Shift+I` goes to GitHub. A card that was not started from an issue says so; with no card selected (after `Esc`) nothing opens. The card's right-click menu has it as **Open issue**; with **Card issue number** on (Settings → Appearance, off by default) the card shows the issue's `#15` at the right of its second row, and a click on it does the same; rebindable as `open_issue` |