Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <host>` opens nebula there, installing it if missing. `nebula tunnel <host>` 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. |

Expand Down
237 changes: 213 additions & 24 deletions crates/nebula-tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<crate::diff_tree::DiffTree>,
/// 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<crate::git_log::GitLog>,
/// 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<std::sync::Arc<[crate::git_diff::SplitRow]>>,
/// 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
Expand All @@ -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<String> {
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>) -> 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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<u16>,
pub diff_sidebar_width: Option<u16>,
/// 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.
Expand Down Expand Up @@ -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<crate::links::FileLink>,
/// 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
Expand Down Expand Up @@ -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<crate::view_jobs::Jobs>,
/// 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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down
Loading