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
181 changes: 149 additions & 32 deletions crates/nebula-tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,23 @@ pub enum Focus {
/// What a screen cell maps to; rebuilt on every draw for hit-testing.
#[derive(Debug, Clone, PartialEq)]
pub enum HitTarget {
/// The GRID's background (registered after the cards, so they win).
/// The GRID's background (registered after the cards, so they win),
/// or a PANELS column's.
PanelBg(Focus),
/// A row of the PANELS — a project, a checkout (or pull request, or
/// issue), a session — or a group header there that folds
/// (`crate::panels::Row`). Registered ahead of its column's
/// `PanelBg`, so it wins.
PanelsRow(crate::panels::Row),
/// The draggable BORDER on a PANELS column's right — its rule and the
/// cell after it — by the column's index, PROJECTS first
/// (`panels::Columns::grab_zone`). Registered ahead of the rows, so a
/// grab there never selects one.
PanelsBorder(usize),
/// A PANELS column's fold button: the `◀` at the right end of its
/// title, which folds it to its RAIL, or the RAIL itself, which opens
/// it again (`event_loop::panels::click_fold`).
PanelsFold(Focus),
TerminalPane,
/// The session URL on the CLOUD SESSION PANEL; a click opens it in the
/// browser. Registered ahead of the pane it sits on, so it wins.
Expand Down Expand Up @@ -2325,10 +2340,12 @@ impl<'a> WorktreeRow<'a> {
}

/// What a click landed on, for the double-click window. Sessions are their
/// own reference; a checkout is its id.
/// own reference; a link has none — a PANELS pull request, issue or link
/// row — so its URL is the identity; a checkout is its id.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RowKey {
Session(SessionRef),
Link(String),
Worktree(WorktreeId),
}

Expand Down Expand Up @@ -2852,6 +2869,11 @@ pub struct UiState {
/// blobs, which open with it showing.
#[serde(default)]
pub launcher_pane_hidden: bool,
/// Widths the PANELS' columns were dragged to; absent in older blobs,
/// and None in ones written before a BORDER was ever dragged, both of
/// which open the columns at `panels::WIDTHS`.
#[serde(default)]
pub panels_widths: Option<[u16; 3]>,
/// The band left open as the ACCORDION
/// ([`App::launcher_expanded`]), by its worktree id; absent, or None,
/// in blobs saved before there was one, which open with every band
Expand Down Expand Up @@ -3286,6 +3308,50 @@ pub struct App {
/// (`event_loop::apply_config`). What a frame lays out is
/// [`App::panel_layout`].
pub launcher_list: bool,
/// The body is the PANELS — PROJECTS | WORKTREES | SESSIONS beside the
/// pane — rather than the PROJECT TABS over the GRID: Settings →
/// Appearance → **Layout** (`event_loop::apply_config`). Only a
/// switch: both draw the one selection (`sel_project`, `sel_worktree`,
/// `sel_session`) and the one pane, so flipping it mid-session lands
/// on the same rows with the same session attached. What a frame
/// draws is [`App::panels_active`].
pub panels: bool,
/// The PANELS' columns' scroll — PROJECTS, WORKTREES, SESSIONS, in
/// that order ([`crate::panels::ColumnScroll`]): the wheel moves a
/// column under its cursor, which stays put, and a move of the
/// cursor brings it back on screen. Drawn by `ui::panels_view`.
pub panels_scroll: [crate::panels::ColumnScroll; 3],
/// Widths the PANELS' columns were dragged to by their BORDERS —
/// PROJECTS, WORKTREES, SESSIONS — in columns; None until one is,
/// which opens them at `panels::WIDTHS`. Re-fitted to the body on
/// every draw (`panels::columns`) without being rewritten, so widths
/// kept from a wider window come back with it. Remembered across
/// restarts.
pub panels_widths: Option<[u16; 3]>,
/// The PANELS' columns folded by hand to their RAILS — PROJECTS,
/// WORKTREES, SESSIONS — by their title's `◀`, their `⇧P` / `⇧B` /
/// `⇧S`, or `^B` for all three (`event_loop::panels`); mirrors
/// CONFIG.JSON's `hide_projects`, `hide_worktrees` and
/// `hide_sessions`, written as they change so a fold survives a
/// restart. A folded column keeps its `panels_widths` entry and opens
/// back up to it. What a frame folds is `panels::folds`, which adds
/// the SESSIONS fold beside a pull request or an issue.
pub panels_hidden: [bool; 3],
/// How many RECENT PROMPTS the PANELS' SESSIONS column hangs under
/// each live session's pill: the **Recent prompts** switch and
/// **Recent prompts shown** (Settings → Appearance, PANELS LAYOUT),
/// resolved through `Config::recent_prompts_shown` — 0 while off. The
/// GRID's cards carry the newest prompt whatever this says.
pub recent_prompts: usize,
/// In-progress drag of a PANELS column's BORDER: the column, and
/// `border column - grab column` at mouse-down, so the border tracks
/// the pointer instead of jumping by one depending on which of the two
/// grab cells was caught (`event_loop::panels::grab_border`).
pub panels_drag: Option<(usize, i32)>,
/// The column whose BORDER is under the mouse, or being dragged: its
/// grip lights up. Carries `hover_launcher_pane`'s caveat — only
/// terminals that report plain motion set it before a drag.
pub hover_panels_border: Option<usize>,
/// Every BAND is laid out open at once — its cards wrapped into rows,
/// or every entry of the LIST listed — and there is no ACCORDION:
/// Settings → Appearance → **Expand all worktrees**
Expand Down Expand Up @@ -3859,6 +3925,13 @@ impl App {
launcher_pane_w: None,
launcher_pane_at: crate::launcher::PaneSide::default(),
launcher_list: false,
panels: false,
panels_scroll: Default::default(),
panels_widths: None,
panels_hidden: [false; 3],
recent_prompts: 0,
panels_drag: None,
hover_panels_border: None,
launcher_all_open: false,
launcher_pane_hidden: false,
launcher_expanded: None,
Expand Down Expand Up @@ -4142,7 +4215,17 @@ impl App {
/// no session has been opened full-screen over it (`collapsed`, which
/// `ui::draw` hands to the pane before it ever reaches the view).
pub fn launcher_grid(&self) -> bool {
self.launcher_active() && !self.collapsed
self.launcher_active() && !self.collapsed && !self.panels
}

/// The PANELS are what the body draws in place of the GRID: the
/// **Layout** setting says so ([`App::panels`]) and there is a project
/// open to draw them for — the same gate as the LAUNCHER VIEW's, so
/// the first run and the all-tabs-closed SPLASH come first either way.
/// True under a full-screen session too, as [`App::launcher_active`]
/// is: the panels are what `^q` comes back down to.
pub fn panels_active(&self) -> bool {
self.panels && self.launcher_active()
}

/// Take the keyboard back from the session in the PANE, and say so
Expand Down Expand Up @@ -4489,16 +4572,18 @@ impl App {

/// Is the left button down, as far as nebula knows — a press came and
/// its release has not: a panel splitter being dragged (the LAUNCHER
/// VIEW's pane edge, the diff and tree modals' file-list border), a
/// program in the pane holding the button, or a drag-selection under
/// way? While it is, the host terminal is left exactly as it is:
/// re-asking it for its modes mid-drag is a change under a gesture in
/// progress, and xterm.js (`nebula browser`) takes the `?1000h` in
/// that re-ask as the end of the drag — it drops its motion listener
/// and reports nothing more until the next press. A motion report
/// with no button named is still the drag.
/// VIEW's pane edge, a PANELS column's BORDER, the diff and tree
/// modals' file-list border), a program in the pane holding the
/// button, or a drag-selection under way? While it is, the host
/// terminal is left exactly as it is: re-asking it for its modes
/// mid-drag is a change under a gesture in progress, and xterm.js
/// (`nebula browser`) takes the `?1000h` in that re-ask as the end of
/// the drag — it drops its motion listener and reports nothing more
/// until the next press. A motion report with no button named is
/// still the drag.
pub fn mouse_held(&self) -> bool {
let splitter = self.launcher_pane_drag.is_some()
|| self.panels_drag.is_some()
|| match &self.overlay {
Some(Overlay::Diff(view)) => view.files_drag.is_some(),
Some(Overlay::Tree(view)) => view.files_drag.is_some(),
Expand Down Expand Up @@ -4542,6 +4627,40 @@ impl App {
.is_some_and(|t| self.is_placeholder_session(&t.sref))
}

/// The attachment is the pane's visible content, rather than a PR,
/// issue or cloud reader covering it. Exited output and starting
/// sessions still belong to this surface and can be full-screened.
pub fn pane_shows_terminal(&self) -> bool {
self.term.is_some()
&& self.rows_memo.hold(|| {
self.previewed_pr().is_none()
&& self.previewed_issue().is_none()
&& self.previewed_cloud().is_none()
})
}

/// Whether the visible pane has a live PTY that can receive input.
/// A reader can cover a retained attachment without detaching it.
pub fn pane_accepts_input(&self) -> bool {
self.pane_shows_terminal()
&& self.term.as_ref().is_some_and(|term| {
!term.exited
&& !self.is_placeholder_session(&term.sref)
&& !self.is_archived_session(&term.sref)
})
}

/// Is this the ARCHIVED agent `id`? Its session was reaped when it was
/// archived, so nothing on it can be stepped into.
pub fn is_archived_agent_id(&self, id: &AgentId) -> bool {
self.tree.agents.iter().any(|a| &a.id == id && a.archived)
}

/// [`App::is_archived_agent_id`] for any session: a terminal never is.
pub fn is_archived_session(&self, sref: &SessionRef) -> bool {
matches!(sref, SessionRef::Agent(id) if self.is_archived_agent_id(id))
}

/// The mouse protocol the program in the pane has asked for, and
/// whether it wants SGR coordinates. `None` when nothing there can take
/// a report: no session, one whose process has exited (its last screen
Expand All @@ -4553,15 +4672,7 @@ impl App {
let Some(term) = &self.term else {
return mouseless;
};
// Asked on every wheel notch over the pane: one reading of the
// cursor for all four (`RowsMemo`).
let reading_something_else = self.rows_memo.hold(|| {
self.pane_shows_placeholder()
|| self.previewed_pr().is_some()
|| self.previewed_issue().is_some()
|| self.previewed_cloud().is_some()
});
if term.exited || reading_something_else {
if !self.pane_accepts_input() {
return mouseless;
}
let screen = term.parser.screen();
Expand Down Expand Up @@ -5291,7 +5402,7 @@ impl App {
}

/// The same for a checkout already in hand ([`App::sessions_in`]).
fn group_counts_in(&self, wt: &WorktreeId) -> (usize, usize) {
pub(crate) fn group_counts_in(&self, wt: &WorktreeId) -> (usize, usize) {
let live = self
.tree
.agents
Expand Down Expand Up @@ -5422,6 +5533,17 @@ impl App {
worktree_rollup(&self.tree, worktree_id)
}

/// The cached changed-file count when it belongs to the selected
/// worktree; `None` while unknown or the checkout is unreadable. The
/// PANELS' footer carries it after the breadcrumb.
pub fn selected_worktree_changes(&self) -> Option<usize> {
let wt = self.selected_worktree()?;
match &self.git_changes {
Some((id, count)) if *id == wt.id => *count,
_ => None,
}
}

/// Whether the checkout's row wears its pull request's merge instead of
/// its sessions' status. The PR ROW keeps a merged pull request
/// (`pull_requests` holds it, state and all), and a checkout whose
Expand Down Expand Up @@ -5471,8 +5593,13 @@ impl App {

/// The two places FOCUS can rest: the LAUNCHER VIEW's GRID of cards
/// and the PANE under them. The three columns the other variants name
/// are no longer drawn.
/// are drawn only by the PANELS ([`App::panels_active`]), where the
/// pane and every column not folded are places to rest
/// (`panels::focus_open`).
pub fn focus_visible(&self, focus: Focus) -> bool {
if self.panels_active() {
return crate::panels::focus_open(self, focus);
}
matches!(focus, Focus::Sessions | Focus::Terminal)
}

Expand Down Expand Up @@ -5534,16 +5661,6 @@ impl App {
/// Test-only accessors: nothing in the app reads these any more.
#[cfg(test)]
impl App {
/// The cached changed-file count when it belongs to the selected
/// worktree; `None` while unknown or the checkout is unreadable.
pub fn selected_worktree_changes(&self) -> Option<usize> {
let wt = self.selected_worktree()?;
match &self.git_changes {
Some((id, count)) if *id == wt.id => *count,
_ => None,
}
}

/// (live, archived) agent counts for the selected worktree.
pub fn session_group_counts(&self) -> (usize, usize) {
let Some(wt) = self.selected_worktree() else {
Expand Down
Loading