diff --git a/Cargo.lock b/Cargo.lock index c3f0a00..a24ad88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -659,6 +659,7 @@ dependencies = [ "ember-session", "libc", "log", + "serde", "serde_json", "toml", "winit", diff --git a/crates/ember-app/Cargo.toml b/crates/ember-app/Cargo.toml index adcf682..978e959 100644 --- a/crates/ember-app/Cargo.toml +++ b/crates/ember-app/Cargo.toml @@ -15,6 +15,7 @@ ember-session.workspace = true ember-render.workspace = true ember-platform.workspace = true winit.workspace = true +serde.workspace = true serde_json.workspace = true toml.workspace = true # The EMBER_FONT_DEBUG logger: surfaces cosmic-text's internal font diff --git a/crates/ember-app/src/main.rs b/crates/ember-app/src/main.rs index 36776af..141b568 100644 --- a/crates/ember-app/src/main.rs +++ b/crates/ember-app/src/main.rs @@ -14,6 +14,7 @@ mod control; #[cfg(unix)] mod mcp; mod screenshot; +mod session_state; mod window_state; use window_state::{DragEnded, HoldTick, MorphTick, WindowState}; @@ -27,10 +28,10 @@ use std::time::{Duration, Instant}; use control::{ControlMsg, MoveTabTarget, PromotePaneTarget}; use ember_core::{ - Axis, BackendControl, BackendEvent, BackendHandle, ClipboardOp, Config, GridDims, LayoutNode, - MoveEffect, MoveError, OscEvent, PaneId, Rect, RowKind, ScrollAmount, SessionId, - SettingsRowView, SparksMode, SurfaceDest, SurfaceRef, Tab, TabId, WispStyle, - WispStyleSelection, resolve_rows, + Axis, BackendControl, BackendEvent, BackendHandle, ClipboardOp, Config, GridDims, + LayoutCommand, LayoutNode, MoveEffect, MoveError, OscEvent, PaneId, Rect, RowKind, + ScrollAmount, SessionId, SettingsRowView, SparksMode, SurfaceDest, SurfaceRef, Tab, TabId, + WispStyle, WispStyleSelection, apply, resolve_rows, }; use ember_platform::{MenuAction, PlatformBackend}; use ember_render::{ @@ -261,6 +262,57 @@ struct App { wake: std::sync::Arc, } +/// Per-session live metadata captured from shell-integration OSC events +/// (design's session-restore feature): the pane's last reported working +/// directory, the last command line the shell announced (OSC 633;E), and +/// whether that command is still running (no matching `CommandEnd` yet). +/// Feeds `session_state::assemble`'s `meta` closure — never logged, never +/// read back out except into a `PaneSnap` for the on-disk snapshot. +#[derive(Default, Clone, Debug)] +pub(crate) struct PaneMeta { + cwd: Option, + last_cmd: Option, + was_running: bool, +} + +/// Build one session's `PaneSnap` from its live `PaneMeta`, honoring the +/// "Capture commands" setting: `last_cmd` is included only when +/// `capture_commands` is true. This is the belt half of "belt and braces" — +/// `Shared::pane_meta` is also cleared directly the instant capture is +/// toggled off (`clear_captured_commands`, called from +/// `WindowState::adjust_setting_and_apply_restore_effects`) — but gating +/// here too means a command can never reach a snapshot while the setting is +/// off, even from a `pane_meta` entry a future call site forgets to scrub. +/// Pure and unit-testable without `Shared`/`WindowState`; `session_dirty`'s +/// `assemble` closure is a thin wrapper around this. +pub(crate) fn pane_snap_for( + meta: Option<&PaneMeta>, + capture_commands: bool, +) -> session_state::PaneSnap { + session_state::PaneSnap { + cwd: meta.and_then(|m| m.cwd.clone()), + last_cmd: if capture_commands { + meta.and_then(|m| m.last_cmd.clone()) + } else { + None + }, + was_running: meta.map(|m| m.was_running).unwrap_or(false), + } +} + +/// Clear `last_cmd` on every tracked pane. The other half of "belt and +/// braces": `session_state::strip_commands` scrubs the on-disk FILE at the +/// instant "Capture commands" is toggled off, but never touches +/// `Shared::pane_meta` — without this, the very next `session_dirty` would +/// reassemble from that still-populated map and silently write every live +/// command straight back on top of the file that was just stripped. Pure +/// over the map so it's unit-testable without a live window. +pub(crate) fn clear_captured_commands(pane_meta: &mut HashMap) { + for meta in pane_meta.values_mut() { + meta.last_cmd = None; + } +} + /// Process-wide state that is not tied to any one window: the running sessions, /// user config, OS effect seam, control socket, and per-session bookkeeping. pub(crate) struct Shared { @@ -335,6 +387,28 @@ pub(crate) struct Shared { /// pane inherits its cwd (design §8.1). Not removed on exit; only read /// while spawning, and a dead `SessionId` is never reused. pub(crate) cwd_by_session: std::collections::HashMap, + /// Live per-session cwd/last-command/running-state, mirrored from the + /// same OSC events as `cwd_by_session` plus OSC 633;E — feeds the + /// session snapshot (`session_dirty`/`session_state::assemble`). Same + /// never-pruned-on-exit convention as `cwd_by_session`: a dead + /// `SessionId` is never reused, and `assemble` only ever looks one up + /// for a pane that's still a live leaf in some window's tree. + pub(crate) pane_meta: std::collections::HashMap, + /// The debounced session-snapshot writer thread's handle, or `None` when + /// no state path resolved at startup (`session_state::state_path()`) — + /// every dirty-marking site and `session_dirty` itself must treat that + /// as a silent no-op, never a panic. + pub(crate) snapshots: Option, + /// Set by every structural or content mutation that should land in the + /// next session snapshot (new/close/rename/split/move a tab or pane, + /// window move/resize/close, a command line or cwd report). A plain + /// bool rather than a direct call at each mutation site: most of those + /// sites are `WindowState` methods with only `&mut Shared` in scope, + /// not the full `windows` map `session_state::assemble` needs to build + /// a snapshot across every window — so marking just flags the need, and + /// `about_to_wait` (which always has both) is the one place that + /// actually flushes it, via the `session_dirty` free function below. + pub(crate) snapshot_dirty: bool, /// Wakes the event loop when a session publishes a frame; registered on /// every session's pixel lane so the loop can idle on `ControlFlow::Wait`. pub(crate) wake: std::sync::Arc, @@ -380,6 +454,43 @@ pub(crate) struct Shared { /// can't reach it); dropped — which is what actually closes the OS /// window — the tick its morph self-terminates. pub(crate) dying_windows: Vec, + /// Restored panes awaiting their saved command being pre-typed (not + /// executed) into the shell once it's ready (Task 8 arms this; Task 9 + /// consumes/clears it). A pane whose saved cwd no longer existed on + /// disk gets NO entry here (see `resolve_restore_cwd`'s doc) — never + /// pre-type a command into a directory it wasn't actually saved from. + pub(crate) pretype: HashMap, + /// A restored window whose split-tree replay is waiting on its + /// `Resized` event (async-resize platforms — see + /// `spawn_restored_window`'s doc for why the replay can't run against + /// the window's still-default size). Drained by the matching + /// `WindowEvent::Resized` handler, or removed without replaying if the + /// window closes first (`close_window`/`close_window_shell_only`). + pub(crate) pending_restore_replay: HashMap, +} + +/// One restored pane's pending pre-type: armed by `replay_restored_window` +/// (Task 8), consumed one-shot (`.remove()`) by the `OscEvent::PromptStart` +/// latch in `about_to_wait` (Task 9) once that pane's own shell reports its +/// first real prompt. `armed_at` lets the latch expire a stale entry (the +/// shell never becomes ready, or an integration signal arrives too late to +/// trust) rather than pre-typing into an arbitrary later prompt — see the +/// 5-second check at the latch's consumption site. +/// +/// Never add a debug print of `cmd` anywhere near this type; the "no +/// logging of command text" rule applies here same as everywhere else a +/// shell command string is held. +pub(crate) struct Pretype { + pub(crate) cmd: String, + pub(crate) armed_at: Instant, +} + +/// A restored window's still-pending tab/split replay — see +/// `Shared::pending_restore_replay`'s doc for when this is used instead of +/// replaying immediately. +pub(crate) struct PendingRestore { + win_snap: session_state::WindowSnap, + session: SessionId, } /// State for an in-flight paced `ctl drag`. Never advanced by a blocking @@ -756,6 +867,11 @@ pub(crate) enum ControlClose { /// Close just this window, unless it turns out to be the last one (then /// quit) — see [`finish_close`]. CloseWindow, + /// The restore modal resolved to an action (Task 8) — carries it back up + /// for the caller to actually perform (spawn windows, archive files): + /// same reasoning as `ExitApp`/`CloseWindow` needing `event_loop`/ + /// `self.windows`, neither reachable from `WindowState::handle_control`. + Restore(window_state::RestoreAction), } /// Inset a rect by `p` on every side (clamped to stay positive). @@ -792,36 +908,29 @@ impl ApplicationHandler for App { if self.shared.is_some() { return; } - let w = DEFAULT_COLS as f32 * CELL_WIDTH + 2.0 * PAD; - let h = DEFAULT_ROWS as f32 * CELL_HEIGHT + 2.0 * PAD; - let attrs = ember_platform::window_attributes("Ember", w, h); - let window = Arc::new(event_loop.create_window(attrs).expect("create window")); - ember_platform::set_app_icon(&window, ICON_PNG); - window.set_ime_allowed(true); // CJK/dead-key composition (winit Ime events) - let window_id = window.id(); - - let size = window.inner_size(); - let px = (size.width.max(1), size.height.max(1)); let config = config::load(); - let renderer = Renderer::new(Arc::clone(&window), &config.font); - - // The seed tab: one pane backed by one shell. - let pane = PaneId(1); - let session = SessionId::new("s1"); - let tree = ember_core::WindowTree { - tabs: vec![Tab { - id: TabId(1), - title: String::new(), - root: LayoutNode::pane(pane, session.clone()), - focus: pane, - }], - active: 0, + let restore_on = config.restore.mode != ember_core::RestoreMode::Off; + // Session-restore (Task 8): what happens to the very first window + // depends on `restore.mode` + whether a snapshot actually loaded. + // `Off`, a missing file (`LoadOutcome::None`), and a + // quarantined-corrupt file (`LoadOutcome::Corrupt` — `load` already + // renamed it aside) all fall through to today's unconditional + // default-window behavior below, since `restore_outcome` stays + // `None` for all three. + let restore_outcome = if restore_on { + session_state::state_path().map(|p| session_state::load(&p)) + } else { + None }; let mut shared = Shared { sessions: HashMap::new(), session_window: HashMap::new(), - window_order: vec![window_id], + // Seeded empty (not with a default window's id, as before): every + // window-creation path — the default window below AND + // `spawn_restored`'s `open_window` calls — now pushes its own id, + // so this stays correct whichever path actually runs. + window_order: Vec::new(), next_pane: 2, next_session: 2, next_tab: 2, @@ -838,37 +947,107 @@ impl ApplicationHandler for App { last_mouse_cell: None, titles: std::collections::HashMap::new(), cwd_by_session: std::collections::HashMap::new(), + pane_meta: std::collections::HashMap::new(), + snapshots: restore_on + .then(|| session_state::state_path().map(session_state::SnapshotWriter::spawn)) + .flatten(), + snapshot_dirty: false, wake: self.wake.clone(), drag: None, new_window_position_hint: None, wisp: WispSlot::Uninit, paced_drag: None, dying_windows: Vec::new(), + pretype: HashMap::new(), + pending_restore_replay: HashMap::new(), }; - let mut win = WindowState::new(renderer, tree); - win.px = px; - if !win.spawn_session( - &mut shared, - session, - GridDims::new(DEFAULT_COLS, DEFAULT_ROWS), - None, - ) { - // No shell at startup means nothing to show; exit with the message - // spawn_session already printed instead of presenting a dead window. - std::process::exit(1); + + // Always: skip the default window entirely — the snapshot's windows + // ARE the session, there's nothing to ask about. + let mut restored_any = false; + if shared.config.restore.mode == ember_core::RestoreMode::Always { + if let Some(session_state::LoadOutcome::Loaded(snap)) = restore_outcome.clone() { + restored_any = spawn_restored( + &mut self.windows, + &mut shared, + &mut self.focused_window, + event_loop, + snap, + ); + // Best-effort initial focus order (real `Focused` events + // reorder this properly once the OS actually focuses one) — + // otherwise-empty `focus_history` would leave every + // window-management helper that reads it with nothing. + for id in self.windows.keys() { + self.focus_history.push(*id); + } + } + } + + if !restored_any { + let w = DEFAULT_COLS as f32 * CELL_WIDTH + 2.0 * PAD; + let h = DEFAULT_ROWS as f32 * CELL_HEIGHT + 2.0 * PAD; + let attrs = ember_platform::window_attributes("Ember", w, h); + let window = Arc::new(event_loop.create_window(attrs).expect("create window")); + ember_platform::set_app_icon(&window, ICON_PNG); + window.set_ime_allowed(true); // CJK/dead-key composition (winit Ime events) + let window_id = window.id(); + + let size = window.inner_size(); + let px = (size.width.max(1), size.height.max(1)); + let renderer = Renderer::new(Arc::clone(&window), &shared.config.font); + + // The seed tab: one pane backed by one shell. + let pane = PaneId(1); + let session = SessionId::new("s1"); + let tree = ember_core::WindowTree { + tabs: vec![Tab { + id: TabId(1), + title: String::new(), + root: LayoutNode::pane(pane, session.clone()), + focus: pane, + }], + active: 0, + }; + + let mut win = WindowState::new(renderer, tree); + win.px = px; + if !win.spawn_session( + &mut shared, + session, + GridDims::new(DEFAULT_COLS, DEFAULT_ROWS), + None, + ) { + // No shell at startup means nothing to show; exit with the message + // spawn_session already printed instead of presenting a dead window. + std::process::exit(1); + } + win.sync_layout(&shared); + win.apply_appearance(&shared); + shared.window_order.push(window_id); + // Ask + a loaded snapshot: show the restore prompt over this + // otherwise-normal default window — the snapshot rides in the + // prompt state (Task 8). Declining ("Start fresh") or confirming + // ("Restore") both resolve through `WindowState::restore_key` / + // `resolve_restore_action` from here on, same as any other + // in-session modal choice. + if shared.config.restore.mode == ember_core::RestoreMode::Ask { + if let Some(session_state::LoadOutcome::Loaded(snap)) = restore_outcome { + win.show_restore_prompt(snap); + } + } + // Paint once now: with ControlFlow::Wait the loop won't run again + // until an event or a frame-lane wake, and the very first frame + // may have been published before the waker was registered. + win.renderer.window().request_redraw(); + self.windows.insert(window_id, win); + self.focused_window = Some(window_id); + self.focus_history.push(window_id); } - win.sync_layout(&shared); - win.apply_appearance(&shared); + if shared.config.developer_mode && shared.control_server.is_none() { shared.set_developer_mode(true); } - // Paint once now: with ControlFlow::Wait the loop won't run again until - // an event or a frame-lane wake, and the very first frame may have been - // published before the waker was registered. - win.renderer.window().request_redraw(); - self.windows.insert(window_id, win); - self.focused_window = Some(window_id); - self.focus_history.push(window_id); self.shared = Some(shared); } @@ -906,7 +1085,29 @@ impl ApplicationHandler for App { WindowEvent::Resized(size) => { win.px = (size.width.max(1), size.height.max(1)); win.renderer.resize(win.px.0, win.px.1); + // A restored window whose split-tree replay was deferred + // here (an async-resize platform — see + // `spawn_restored_window`'s doc): the real size is now + // known, so replay against it before the ordinary + // `sync_layout` below, which must see the fully rebuilt + // tree rather than just the still-bare seed tab. + if let Some(pending) = shared.pending_restore_replay.remove(&id) { + if !replay_restored_window(win, shared, &pending.win_snap, pending.session) { + // Tab 0's shell failed to spawn: nothing to show. + // `win` isn't referenced again in this arm, so + // `close_window` can freely reborrow `self.windows`. + close_window(&mut self.windows, shared, &mut self.focused_window, id); + return; + } + } win.sync_layout(shared); + shared.snapshot_dirty = true; + } + // No per-window bookkeeping needed here (`session_dirty` reads + // the live position straight from winit) — just mark the + // snapshot dirty so the moved position gets picked up. + WindowEvent::Moved(_) => { + shared.snapshot_dirty = true; } WindowEvent::Focused(focused) => { win.window_focused = focused; @@ -996,9 +1197,14 @@ impl ApplicationHandler for App { } => match button { MouseButton::Left => { let (x, y) = win.cursor; - // A blocking confirm modal captures the click: a button - // resolves it, elsewhere is a no-op (stays modal). - if win.pending_close.is_some() { + // The restore modal is keyboard-only (Task 8: no mouse + // hit-testing for its buttons/rows) — swallow a click + // rather than let it fall through to the panes/tabs + // underneath, same "stays modal" intent as the + // confirm-modal click guard just below. + if win.restore_prompt.is_some() { + // no-op: click doesn't resolve or dismiss the modal + } else if win.pending_close.is_some() { if let Some(idx) = win.renderer.confirm_button_at(x as f32, y as f32) { let kind = win.pending_close; if win.resolve_confirm(shared, idx == 1) { @@ -1186,6 +1392,37 @@ impl ApplicationHandler for App { win.rename_key(shared, &key.logical_key); return; } + // The restore-on-launch modal (Task 8): Left/Right/Tab/Up/Down + // navigate, Enter activates, Esc backs out, a printable + // keystroke types through (dismisses as Start Fresh and + // forwards the text — see `restore_key`/`RestoreAction:: + // StartFreshAndType`). Auto-repeat ignored so a held key + // can't fire an action twice. + // + // NOT captured here while Super is held: Cmd+Q, Cmd+N, + // Cmd+W, etc. must keep working while this modal is up + // rather than being dismissed-and-typed-through as a stray + // letter — same "capture typing, but not Cmd combos" guard + // the palette/search/rename branches above already use, so + // Super-held keys fall through to the Cmd+Q handler and the + // Super-shortcut block further down, untouched. + if win.restore_prompt.is_some() && !win.modifiers.super_key() { + if !key.repeat { + if let Some(action) = win.restore_key(&key.logical_key, win.modifiers) { + resolve_restore_action( + &mut self.windows, + shared, + &mut self.focused_window, + event_loop, + id, + action, + ); + } else { + win.renderer.window().request_redraw(); + } + } + return; + } // A running-process close confirmation (modal): Left/Right/Tab // move focus, Enter activates it, Esc cancels. Auto-repeat is // ignored so a held key can't confirm. @@ -1558,6 +1795,19 @@ impl ApplicationHandler for App { // (no UI surfaces it); tracked here anyway so the protocol is complete // and a future feature (tab title, triggers) can read it. let mut cwd_updates: Vec<(SessionId, String)> = Vec::new(); + // OSC 633;E command-line reports and their matching `CommandEnd`, for + // the session snapshot's `last_cmd`/`was_running` (never logged — + // stored only in `Shared::pane_meta` and, capped, in the on-disk + // snapshot). Collected here rather than applied in-loop for the same + // reason `cwd_updates` is: this loop borrows `shared.sessions`. + let mut cmd_updates: Vec<(SessionId, String)> = Vec::new(); + let mut cmd_ends: Vec = Vec::new(); + // OSC 133;A prompt-start reports — the latch a restored pane's + // `Shared::pretype` entry (Task 8) waits on before it's safe to type + // its saved command. Collected here (not applied in-loop) for the + // same reason `cwd_updates`/`cmd_updates` are: this loop borrows + // `shared.sessions`. + let mut prompt_starts: Vec = Vec::new(); for (id, handle) in &shared.sessions { while let Ok(event) = handle.events.try_recv() { match event { @@ -1577,6 +1827,17 @@ impl ApplicationHandler for App { BackendEvent::Osc(OscEvent::CurrentDir(path)) => { cwd_updates.push((id.clone(), path)); } + BackendEvent::Osc(OscEvent::CommandLine(cmd)) => { + if shared.config.restore.capture_commands { + cmd_updates.push((id.clone(), cmd)); + } + } + BackendEvent::Osc(OscEvent::CommandEnd(_)) => { + cmd_ends.push(id.clone()); + } + BackendEvent::Osc(OscEvent::PromptStart) => { + prompt_starts.push(id.clone()); + } _ => {} } } @@ -1588,12 +1849,63 @@ impl ApplicationHandler for App { shared .titles .retain(|id, _| shared.sessions.contains_key(id)); + // Session-snapshot metadata (design's session-restore feature): a + // command-line/end report or a cwd report mutates `pane_meta` and + // marks the snapshot dirty. The actual assemble+queue happens once, + // in `about_to_wait`'s tail, via `session_dirty` — never here, since + // this function doesn't have `self.windows` in scope. + let mut meta_dirty = false; for (id, cwd) in cwd_updates { - shared.cwd_by_session.insert(id, cwd); + shared.cwd_by_session.insert(id.clone(), cwd.clone()); + shared.pane_meta.entry(id).or_default().cwd = Some(cwd); + meta_dirty = true; } shared .cwd_by_session .retain(|id, _| shared.sessions.contains_key(id)); + for (id, cmd) in cmd_updates { + let meta = shared.pane_meta.entry(id).or_default(); + meta.last_cmd = Some(cmd); + meta.was_running = true; + meta_dirty = true; + } + for id in cmd_ends { + if let Some(meta) = shared.pane_meta.get_mut(&id) { + meta.was_running = false; + meta_dirty = true; + } + } + // Pre-type latch (Task 9): a restored pane's saved command types + // itself into the shell's edit buffer, unsent, the moment that + // pane's OWN shell reports its first real prompt — never before + // (nothing is ready to receive it) and only once (`.remove()` makes + // this one-shot by construction; a shell that reports a second + // `PromptStart` before restart finds nothing left armed). A pane + // with no shell-integration hook installed never fires + // `PromptStart` at all, so it never pre-types — matches the spec. + for id in prompt_starts { + let Some(p) = shared.pretype.remove(&id) else { + continue; + }; + if p.armed_at.elapsed() > Duration::from_secs(5) { + // The integration signal arrived too late to trust — the + // shell may have already processed unrelated input by now. + // Drop silently rather than typing into a stale context. + continue; + } + // `shared.bracketed` may still lag the shell's actual mode this + // early (bracketed-paste mode is itself set by an OSC/DEC report + // that can arrive after this first `PromptStart`); worst case + // that's read as `false` here, so `pretype_bytes` falls into its + // newline-flattening arm — never the unbracketed-and-unguarded + // path that could let a stray `\n`/`\r` execute. + let bracketed = shared.bracketed.get(&id).copied().unwrap_or(false); + let bytes = pretype_bytes(&p.cmd, bracketed); + window_state::send_to_pane(shared, &id, bytes); + } + if meta_dirty { + shared.snapshot_dirty = true; + } if let Some(text) = clipboard_set { shared.platform.set_clipboard(&text); } @@ -1810,6 +2122,9 @@ impl ApplicationHandler for App { Some(ControlClose::CloseWindow) => { queue_close_this(&mut deferred_windows); } + Some(ControlClose::Restore(action)) => { + deferred_windows.push(DeferredWindowAction::Restore(focused_id, action)); + } None => {} } } @@ -2240,6 +2555,16 @@ impl ApplicationHandler for App { (Err(e), None) => eprintln!("[ember] move failed: {e}"), } } + DeferredWindowAction::Restore(prompting_window, action) => { + resolve_restore_action( + &mut self.windows, + shared, + &mut self.focused_window, + event_loop, + prompting_window, + action, + ); + } DeferredWindowAction::State(reply) => { let json = build_state_json(shared, &self.windows, self.focused_window); let _ = reply.send(json); @@ -2289,6 +2614,27 @@ impl ApplicationHandler for App { } } } + + // Session-snapshot flush: every deferred window action above has run, + // so `self.windows` is fully available again (no outstanding `win` + // borrow) — the one point in this function that can actually call + // `session_dirty`. Gated on the flag so a tick with nothing new to + // save doesn't pay for a tree walk (this function runs on every + // event-loop wake, not just ones a mutation caused). + // + // The clear is gated on the SAME "no restore prompt is up" check + // `session_dirty` itself applies internally — not just deferred to + // it — so a Moved/Resized delta that arrives while the modal is + // still up doesn't get its dirty bit silently thrown away with + // nothing ever written: the flag stays `true` and this same block + // flushes it for real on the first tick after the modal resolves. + if shared.snapshot_dirty { + let restore_prompt_open = self.windows.values().any(|w| w.restore_prompt.is_some()); + if !restore_prompt_open { + session_dirty(shared, &self.windows); + shared.snapshot_dirty = false; + } + } } } @@ -2303,6 +2649,14 @@ impl ApplicationHandler for App { enum DeferredWindowAction { OpenNew(Option), CloseThis, + /// The restore modal (Task 8) resolved to an action from a `ctl` + /// command — deferred for the same reason every other window-structural + /// discovery site here is: `spawn_restored` needs `&mut self.windows`/ + /// `&ActiveEventLoop`, neither available while `win` still borrows + /// `self.windows` for the rest of the ctl-commands loop. Carries the + /// PROMPTING window's id (captured as `focused_id` at enqueue time) so + /// `resolve_restore_action` can close it once the restore actually lands. + Restore(WindowId, window_state::RestoreAction), /// Close a specific, possibly NON-focused window whose tree just emptied /// (a background window's last shell exited via the exited-shell drain). /// `CloseThis` always targets `focused_id` captured at the top of @@ -2471,6 +2825,14 @@ fn open_window( win.renderer.window().request_redraw(); windows.insert(window_id, win); shared.window_order.push(window_id); + // A brand-new window (Cmd+N, ctl new-window, or a promote/move that + // mints one) is itself a structural change the snapshot must pick up — + // without this, a window that never sees a further mutation (no split, + // no rename, nothing) would simply never reach disk. Both callers + // already hold `shared`/`windows` with no borrow conflict, so this is a + // direct funnel call, not just a flag (matching `finish_close`/ + // `apply_move`'s pattern). + session_dirty(shared, windows); window_id } @@ -2518,6 +2880,453 @@ fn open_new_window( window_id } +// --- Session restore: skeleton rebuild (Task 8) ---------------------------- + +/// Carry out a resolved [`window_state::RestoreAction`]: archive the current +/// live session file when required, then either rebuild every window from +/// the chosen snapshot ("Restore" / an `Older…` pick) or just leave the +/// already-created default window in place ("Start fresh"). +fn resolve_restore_action( + windows: &mut HashMap, + shared: &mut Shared, + focused_window: &mut Option, + event_loop: &ActiveEventLoop, + prompting_window: WindowId, + action: window_state::RestoreAction, +) { + match action { + window_state::RestoreAction::StartFresh => { + if let Some(path) = session_state::state_path() { + if let Err(e) = session_state::archive(&path) { + eprintln!("[ember] archive on start-fresh failed: {e}"); + } + } + } + window_state::RestoreAction::StartFreshAndType(text) => { + // Same archive semantics as plain `StartFresh`; the prompting + // window's already-live shell (the "fresh" session) then gets + // the keystroke it would otherwise have lost. Reuses + // `send_to_focused` — the same write path `ControlMsg::Type` + // uses on a modal-less window — so this isn't a second way to + // put bytes on a pty. + if let Some(path) = session_state::state_path() { + if let Err(e) = session_state::archive(&path) { + eprintln!("[ember] archive on start-fresh failed: {e}"); + } + } + if let Some(win) = windows.get(&prompting_window) { + win.send_to_focused(shared, text.into_bytes()); + } + } + window_state::RestoreAction::Restore { + snapshot, + archive_current, + } => { + if archive_current { + // An `Older…` pick: the live file is still sitting there and + // would otherwise simply be overwritten by the restored + // session's own first snapshot — archive it first so it + // isn't silently lost. + if let Some(path) = session_state::state_path() { + if let Err(e) = session_state::archive(&path) { + eprintln!("[ember] archive before restoring an older session failed: {e}"); + } + } + } + let restored_any = + spawn_restored(windows, shared, focused_window, event_loop, snapshot); + // The prompt's own window (a brand-new, still-idle default + // window created solely to host the Ask-mode prompt) is now + // redundant — the restored windows ARE the session, matching + // `Always` mode, and leaving it open would both show a stray + // empty extra window and get it swept into the very next + // snapshot (compounding: "Restore 3 windows…" on the next + // launch). Only close it once at least one window actually + // restored — a degenerate/all-failed snapshot keeps the + // prompting window as the fallback, so the app never ends up + // with zero windows. `close_window` (not `finish_close`) is the + // right primitive here: it never checks "is this the last + // window" / calls `event_loop.exit()`, and by this point the + // restored windows already exist in `windows` (spawn_restored + // ran first) and `*focused_window` already points at one of + // them (spawn_restored's own tail sets it), so closing the + // prompting window can't trip app-exit logic and focus stays on + // a restored window (its own `if *focused_window == Some(id)` + // check is already false). The prompting window's seed shell is + // brand new and definitionally idle, so no confirm is needed — + // same "unconditional, no confirm" reasoning `open_new_window`'s + // own failure-path close already uses. + if restored_any { + close_window(windows, shared, focused_window, prompting_window); + } + } + } +} + +/// Resolve a saved pane's spawn cwd + an optional pre-type command. A saved +/// cwd that still exists on disk is used as-is, with its `last_cmd` (if any) +/// eligible for pre-type; a missing/absent cwd falls back to `$HOME` with NO +/// pre-type — the saved command's working-directory context is gone, so +/// blindly retyping it there would be actively misleading, not helpful. +fn resolve_restore_cwd(pane: &session_state::PaneSnap) -> (Option, Option) { + if let Some(dir) = pane.cwd.as_deref() { + if std::path::Path::new(dir).is_dir() { + return (Some(dir.to_string()), pane.last_cmd.clone()); + } + } + let home = std::env::var_os("HOME").map(|h| h.to_string_lossy().into_owned()); + (home, None) +} + +/// Clamp a saved window position onto a visible monitor: if `pos`'s origin +/// already lies within some monitor's rect, it's left untouched; otherwise +/// it's repositioned onto the NEAREST visible monitor (by center-to-center +/// distance), clamped so the window's own `size` stays fully on that +/// monitor wherever the monitor is large enough for it. `monitors`: +/// `(x, y, w, h)` physical-px rects, extracted by the caller from +/// `event_loop.available_monitors()` — pure geometry, no `ActiveEventLoop` +/// needed here, so it's unit-testable on its own. An empty `monitors` list +/// (can't happen on a real event loop, but never assume) leaves `pos` +/// unchanged rather than panicking. +fn clamp_to_visible_monitor( + pos: (i32, i32), + size: (u32, u32), + monitors: &[(i32, i32, u32, u32)], +) -> (i32, i32) { + if monitors.is_empty() { + return pos; + } + let on_screen = monitors.iter().any(|&(mx, my, mw, mh)| { + pos.0 >= mx && pos.0 < mx + mw as i32 && pos.1 >= my && pos.1 < my + mh as i32 + }); + if on_screen { + return pos; + } + let (px, py) = (pos.0 as f64, pos.1 as f64); + let dist2 = |m: &(i32, i32, u32, u32)| { + let cx = m.0 as f64 + m.2 as f64 / 2.0; + let cy = m.1 as f64 + m.3 as f64 / 2.0; + (px - cx).powi(2) + (py - cy).powi(2) + }; + let &(mx, my, mw, mh) = monitors + .iter() + .min_by(|a, b| { + dist2(a) + .partial_cmp(&dist2(b)) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .expect("monitors is non-empty (checked above)"); + let x = pos + .0 + .clamp(mx, (mx + mw as i32).saturating_sub(size.0 as i32).max(mx)); + let y = pos + .1 + .clamp(my, (my + mh as i32).saturating_sub(size.1 as i32).max(my)); + (x, y) +} + +/// Rebuild every window from a loaded [`session_state::SessionSnapshot`] +/// (Task 8): each window via [`spawn_restored_window`], positioned onto a +/// visible monitor via [`clamp_to_visible_monitor`]. Sets `*focused_window` +/// to the first window actually created (if any) and requests its first +/// paint — same "paint once now" reasoning as `resumed`'s own default +/// window, since the loop won't run again until an event or frame-lane wake. +/// Returns whether at least one window was actually created — callers use +/// this to decide whether a fallback default window is still needed +/// (`resumed`'s `Always` path) or whether a now-redundant prompting window +/// should be closed (`resolve_restore_action`'s `Ask` path). +fn spawn_restored( + windows: &mut HashMap, + shared: &mut Shared, + focused_window: &mut Option, + event_loop: &ActiveEventLoop, + snapshot: session_state::SessionSnapshot, +) -> bool { + let monitors: Vec<(i32, i32, u32, u32)> = event_loop + .available_monitors() + .map(|m| { + let p = m.position(); + let s = m.size(); + (p.x, p.y, s.width, s.height) + }) + .collect(); + let mut first_window: Option = None; + for win_snap in &snapshot.windows { + let position = win_snap.pos.map(|(x, y)| { + let (cx, cy) = clamp_to_visible_monitor((x, y), win_snap.size, &monitors); + winit::dpi::PhysicalPosition::new(cx, cy) + }); + if let Some(window_id) = spawn_restored_window( + windows, + shared, + focused_window, + event_loop, + win_snap, + position, + ) { + first_window.get_or_insert(window_id); + } + } + if let Some(id) = first_window { + *focused_window = Some(id); + if let Some(win) = windows.get_mut(&id) { + win.renderer.window().request_redraw(); + } + } + first_window.is_some() +} + +/// Build ONE restored window from its `WindowSnap`: created via +/// [`open_window`] (the same low-level path every other window-creation +/// site uses — `set_ime_allowed`, app-icon, replay-seeding all included), +/// resized to its saved size, then its tabs/splits replayed via +/// [`replay_restored_window`]. +/// +/// The resize matters for correctness, not just cosmetics: +/// `split_pane_with_cwd`'s min-size acceptance check reads the window's +/// CURRENT `px`, and a freshly created window starts at the app's default +/// size — replaying a dense saved split tree against that default (rather +/// than the saved, possibly much larger, size) can silently refuse splits +/// that would fit fine once actually resized. `Window::request_inner_size` +/// is synchronous on some platforms (macOS, Windows, X11) and returns +/// `Some(actual_size)` immediately, in which case the replay runs right +/// here against the corrected size; on others (Wayland, and X11 in +/// practice) it's only a request — returns `None`. When that request is a +/// genuine size change, the real size arrives later as a +/// `WindowEvent::Resized`, so the whole tab/split replay is deferred via +/// `Shared::pending_restore_replay` until `window_event`'s `Resized` arm +/// drains it. But when the requested size already equals the window's +/// current size (a very common case — the saved size matches the default +/// creation size), the request is a no-op and no `Resized` event is ever +/// delivered: deferring unconditionally on `None` left the window parked +/// forever, with no shell ever spawned — a dead black pane with no input. +/// So a `None` result is only deferred when the requested size actually +/// differs from the window's current size (see `needs_deferred_replay`); +/// otherwise (equal sizes, or a degenerate zero-sized saved window) the +/// replay runs immediately against the window's current size instead. +/// Either way `replay_restored_window` is the one place that actually +/// builds the tabs/splits, so the paths can't drift apart. +/// +/// Returns the new window's id (which exists either way — with a live +/// first pane if replayed synchronously, or still just the bare seed pane +/// if deferred), or `None` if the synchronous replay's very first pane +/// failed to spawn — that failure tears the window back down immediately +/// (mirroring `open_new_window`'s own failure handling) rather than +/// deferring anything. The async/deferred path's own first-pane failure is +/// handled later, by the `Resized` handler, the same way. +fn spawn_restored_window( + windows: &mut HashMap, + shared: &mut Shared, + focused_window: &mut Option, + event_loop: &ActiveEventLoop, + win_snap: &session_state::WindowSnap, + position: Option>, +) -> Option { + let pane = PaneId(shared.next_pane); + shared.next_pane += 1; + let session = SessionId::new(format!("s{}", shared.next_session)); + shared.next_session += 1; + let tab_id = TabId(shared.next_tab); + shared.next_tab += 1; + let tree = ember_core::WindowTree { + tabs: vec![Tab { + id: tab_id, + title: String::new(), + root: LayoutNode::pane(pane, session.clone()), + focus: pane, + }], + active: 0, + }; + let window_id = open_window(windows, shared, event_loop, tree, position); + let win = windows.get_mut(&window_id)?; + + let current_px = win.px; + let applied_synchronously = if win_snap.size.0 > 0 && win_snap.size.1 > 0 { + win.renderer + .window() + .request_inner_size(winit::dpi::PhysicalSize::new( + win_snap.size.0, + win_snap.size.1, + )) + } else { + None + }; + match applied_synchronously { + Some(new_size) => { + // Synchronous platform: the resize already landed — mirror + // `WindowEvent::Resized`'s own bookkeeping right here so the + // replay below sees the corrected size, not the stale default. + win.px = (new_size.width.max(1), new_size.height.max(1)); + win.renderer.resize(win.px.0, win.px.1); + if !replay_restored_window(win, shared, win_snap, session) { + close_window(windows, shared, focused_window, window_id); + return None; + } + } + None if needs_deferred_replay(win_snap.size, current_px) => { + // Async platform, and the requested size genuinely differs + // from the window's current size: only a request went out. + // Defer the whole replay until the matching `Resized` event + // tells us the real size (`window_event`'s `Resized` arm + // drains this). + shared.pending_restore_replay.insert( + window_id, + PendingRestore { + win_snap: win_snap.clone(), + session, + }, + ); + } + None => { + // No genuine resize is coming — either the saved size already + // matches the window's current size (a no-op request produces + // no `Resized` event), or the saved size was degenerate + // (zero-width/height). `win.px` is already correct, so replay + // immediately at the current size rather than waiting forever + // for an event that will never arrive. + if !replay_restored_window(win, shared, win_snap, session) { + close_window(windows, shared, focused_window, window_id); + return None; + } + } + } + Some(window_id) +} + +/// Whether a restored window's saved size, if requested via +/// `Window::request_inner_size` and not applied synchronously, needs its +/// tab/split replay deferred until the resulting `WindowEvent::Resized` +/// (versus running the replay immediately against the window's current +/// size because no such event is actually coming). False when `requested` +/// already equals `current` (a no-op resize request — no `Resized` event +/// follows) or when either dimension of `requested` is zero (a degenerate +/// saved size, never actually requested); true otherwise. +fn needs_deferred_replay(requested: (u32, u32), current: (u32, u32)) -> bool { + requested.0 > 0 && requested.1 > 0 && requested != current +} + +/// Replay one restored window's tabs + split trees onto an already-created, +/// correctly-sized `WindowState` — see [`spawn_restored_window`]'s doc for +/// why this sometimes runs immediately and sometimes waits for a `Resized` +/// event first. First tab spawned directly, remaining tabs via +/// `new_tab_with_cwd`, each tab's split tree replayed via +/// `session_state::split_ops` through `split_pane_with_cwd` (WITH an +/// explicit cwd every time — the normal `split_pane` cwd-inheritance has +/// nothing to inherit from a shell that was only just spawned), titles + +/// `named_tabs` for `named_by_user` tabs, and the saved active tab. Every +/// pane with a saved `last_cmd` and a cwd that still exists gets a +/// `Shared::pretype` entry (Task 9 consumes it); a cwd-fallback pane gets +/// none (see `resolve_restore_cwd`'s doc). +/// +/// Returns `false` only if the window's very first pane's shell failed to +/// spawn — the caller tears the window down in that case. A LATER tab's +/// shell failing is not fatal to the window: that one tab is simply +/// skipped (`continue`) and the rest of the window still builds. +fn replay_restored_window( + win: &mut WindowState, + shared: &mut Shared, + win_snap: &session_state::WindowSnap, + session: SessionId, +) -> bool { + for (i, tab_snap) in win_snap.tabs.iter().enumerate() { + let (first_pane, ops) = session_state::split_ops(&tab_snap.splits); + let (cwd, pretype_cmd) = resolve_restore_cwd(&first_pane); + let ok = if i == 0 { + win.spawn_session( + shared, + session.clone(), + GridDims::new(DEFAULT_COLS, DEFAULT_ROWS), + cwd, + ) + } else { + win.new_tab_with_cwd(shared, cwd) + }; + if !ok { + if i == 0 { + // The window's very first pane failed to spawn: nothing to + // show for this window at all. + return false; + } + continue; // this tab's shell failed; skip it, keep the rest of the window + } + let tab_idx = win.tree.active; // `NewTab` sets this; tab 0 keeps its initial 0. + let seed_pane = win.tree.tabs[tab_idx].focus; + if let Some(cmd) = pretype_cmd { + if let Some(sid) = win.tree.tabs[tab_idx].root.session_of(seed_pane) { + shared.pretype.insert( + sid.clone(), + Pretype { + cmd, + armed_at: Instant::now(), + }, + ); + } + } + let tid = win.tree.tabs[tab_idx].id; + if !tab_snap.name.is_empty() { + let vp = win.viewport(); + apply( + &mut win.tree, + LayoutCommand::RenameTab { + tab: tid, + title: tab_snap.name.clone(), + }, + vp, + ); + } + if tab_snap.named_by_user { + win.named_tabs.insert(tid); + } + // Splits: replay `ops` (pre-order, parent-index-addressed — see + // `split_ops`'s doc) through `split_pane_with_cwd`, tracking each + // new pane's id by creation order so later ops can address it. + let mut pane_by_index = vec![seed_pane]; + for (parent_idx, dir, ratio, pane_snap) in &ops { + let Some(&parent_pane) = pane_by_index.get(*parent_idx) else { + continue; // a prior op in this tab was refused; nothing to attach to + }; + let axis = if *dir == 'h' { + Axis::Horizontal + } else { + Axis::Vertical + }; + let (op_cwd, op_pretype) = resolve_restore_cwd(pane_snap); + let before_focus = win.tree.tabs[tab_idx].focus; + win.split_pane_with_cwd(shared, parent_pane, axis, *ratio as f64, op_cwd); + let after_focus = win.tree.tabs[tab_idx].focus; + if after_focus == before_focus { + // Refused (the window's ACTUAL size — already corrected + // before this runs, see `spawn_restored_window`'s doc — + // still can't fit the min pane extent along this axis, a + // genuinely tiny saved window) — degrade: later ops that + // wanted to attach here instead attach to the still-intact + // parent, so the rest of the tree still builds as best it can. + pane_by_index.push(parent_pane); + continue; + } + pane_by_index.push(after_focus); + if let Some(cmd) = op_pretype { + if let Some(sid) = win.tree.tabs[tab_idx].root.session_of(after_focus) { + shared.pretype.insert( + sid.clone(), + Pretype { + cmd, + armed_at: Instant::now(), + }, + ); + } + } + } + } + + if !win.tree.tabs.is_empty() { + win.tree.active = win_snap.focused_tab.min(win.tree.tabs.len() - 1); + } + win.sync_layout(shared); + win.apply_appearance(shared); + true +} + /// Clear `shared.drag` if window `id` was its source, so a source window /// closing mid-drag doesn't leave the global key-swallow (the /// `shared.drag.is_some()` check in the `KeyboardInput` handler above) stuck @@ -2944,6 +3753,12 @@ fn close_window( id: WindowId, ) { clear_drag_on_window_close(windows, shared, id); + // A restored window can close (spawn failure, or the user quitting) + // before its deferred split-tree replay ever got its `Resized` event — + // drop the pending replay rather than leave a stale entry that would + // otherwise sit in the map forever (or, worse, replay onto a reused + // `WindowId` the OS hands out later). + shared.pending_restore_replay.remove(&id); if let Some(win) = windows.remove(&id) { for sid in win.window_session_ids() { if let Some(h) = shared.sessions.remove(&sid) { @@ -2975,6 +3790,9 @@ fn close_window_shell_only( id: WindowId, ) { clear_drag_on_window_close(windows, shared, id); + // See `close_window`'s matching comment: never leave a stale pending + // replay behind for a window that's gone. + shared.pending_restore_replay.remove(&id); let removed = windows.remove(&id); shared.window_order.retain(|w| *w != id); if *focused_window == Some(id) { @@ -3002,6 +3820,70 @@ fn close_window_shell_only( } } +/// The single funnel between a pending session-snapshot mutation +/// (`shared.snapshot_dirty`) and the debounced on-disk writer: assembles the +/// live window/tab/split/pane state (`session_state::assemble`) from every +/// window in `shared.window_order` and every session's `shared.pane_meta`, +/// then hands it to the snapshot writer thread. A no-op — cheap, no tree +/// walk — when `shared.snapshots` is `None` (no state path resolved, or a +/// future config gate turned restore off). +/// +/// Called from `about_to_wait`'s tail (gated on the dirty flag) and directly +/// from the couple of sites that already hold both `shared` and `windows` +/// with no outstanding borrow conflict: `finish_close` (a window closing +/// changes the window list itself) and `apply_move` (cross-window tab/pane +/// moves). +fn session_dirty(shared: &mut Shared, windows: &HashMap) { + let Some(handle) = shared.snapshots.as_ref() else { + return; + }; + // While the restore-on-launch modal is up (`restore.mode == Ask`), the + // on-disk session file must stay untouched: it's the very thing the + // modal is offering to restore, and `Older…`'s `Esc` reloads it + // verbatim to rebuild the `Main` screen (`WindowState::restore_key`'s + // doc) — a background PTY delta (a resize, a cwd report) marking + // `snapshot_dirty` while the prompt is still up must not silently + // overwrite it out from under the user before they've answered. + if windows.values().any(|w| w.restore_prompt.is_some()) { + return; + } + // `named_by_user` has to be an owned `Vec` per window (nothing to + // borrow it from — `WindowState::named_tabs` is a `HashSet`, not + // a slice), so this builds the owned flags first, then a second pass of + // `session_state::WindowInput` tuples borrowing both that and each + // window's `tree`. + let per_window = shared + .window_order + .iter() + .filter_map(|wid| windows.get(wid)) + .map(|w| { + let pos = w + .renderer + .window() + .outer_position() + .ok() + .map(|p| (p.x, p.y)); + let named_by_user: Vec = w + .tree + .tabs + .iter() + .map(|t| w.named_tabs.contains(&t.id)) + .collect(); + (pos, w.px, &w.tree, named_by_user) + }) + .collect::>(); + let refs: Vec = per_window + .iter() + .map(|(pos, px, tree, named)| (*pos, *px, *tree, named.as_slice())) + .collect(); + let pane_meta = &shared.pane_meta; + let capture_commands = shared.config.restore.capture_commands; + let snap = session_state::assemble(&refs, &|sid: &SessionId| { + pane_snap_for(pane_meta.get(sid), capture_commands) + }); + handle.update(snap); +} + /// The shared tail of every "this close/quit was confirmed (or needed no /// confirmation)" path: if `id` is the only window left, closing it is /// indistinguishable from quitting the app, so do the full app-wide shutdown; @@ -3030,6 +3912,11 @@ fn finish_close( event_loop.exit(); } else { close_window(windows, shared, focused_window, id); + // The window list itself just changed — the next snapshot must drop + // the closed window. (On the full-shutdown branch above there's no + // point: the process is exiting and nothing will read the file + // until next launch.) + session_dirty(shared, windows); } } @@ -3086,6 +3973,31 @@ fn apply_move( shared.next_tab += 1; let final_focused = model.focused; + // Task 5 fix: a whole-tab move (SurfaceRef::Tab) preserves the tab's + // TabId across the move (core's own doc: "Tab-sourced moves carry their + // existing Tab, id included, wholesale") -- EXCEPT a merge (SplitInto), + // which dissolves the tab into a pane of another tab, so there is no + // destination tab id to carry into. Snapshot which tab (if any) was + // user-renamed BEFORE the move touches `windows`, so the carry below + // (after the move lands) knows whether to follow it. + let renamed_move = if let SurfaceRef::Tab { + window: sw_idx, + tab: st_idx, + } = src + { + orig_order.get(sw_idx).and_then(|src_wid| { + let src_wid = *src_wid; + let moved_tab_id = windows.get(&src_wid)?.tree.tabs.get(st_idx)?.id; + windows + .get(&src_wid)? + .named_tabs + .contains(&moved_tab_id) + .then_some((src_wid, moved_tab_id)) + }) + } else { + None + }; + // Stable-sort so any `WindowClosed` always processes LAST, regardless of // where `move_surface` put it in the returned `Vec`. The rest of this // function (the `SessionsRehomed`-before-`WindowClosed` re-homing dance) @@ -3207,6 +4119,33 @@ fn apply_move( w.renderer.window().request_redraw(); } } + // Carry a moved tab's `named_by_user` membership across the window + // boundary: `renamed_move` (captured before the move, above) names the + // source window + TabId only when that tab was actually user-renamed. + // The destination is whichever window's tree now holds that same + // TabId -- found by `window_owning_tab` rather than threaded through + // `new_order`, since it's the one thing true for every dest kind that + // preserves the id (NewTab, NewWindow) and simply absent for one that + // doesn't (SplitInto/merge dissolves the tab, so no window will match + // and this is correctly a no-op). + if let Some((src_wid, moved_tab_id)) = renamed_move { + if let Some(src_w) = windows.get_mut(&src_wid) { + src_w.named_tabs.remove(&moved_tab_id); + } + let tabs_by_window: Vec<(WindowId, Vec)> = windows + .iter() + .map(|(wid, w)| (*wid, w.tree.tabs.iter().map(|t| t.id).collect())) + .collect(); + if let Some(dest_wid) = window_owning_tab(&tabs_by_window, moved_tab_id) { + if let Some(dest_w) = windows.get_mut(&dest_wid) { + dest_w.named_tabs.insert(moved_tab_id); + } + } + } + // Every surface-mobility gesture (move-tab/promote-pane/merge-tab, a + // real drag-drop) lowers onto this function, so this one call covers + // all of them for the session snapshot. + session_dirty(shared, windows); Ok(()) } @@ -3252,6 +4191,19 @@ fn next_prev_index(w: usize, n: usize, next: bool) -> usize { if next { (w + 1) % n } else { (w + n - 1) % n } } +/// The window (if any) whose tab list contains `tab_id` -- the search half +/// of `apply_move`'s cross-window `named_tabs` carry (Task 5 fix #4: a +/// user-renamed tab dragged to another window was silently losing +/// `named_by_user`). Generic over the window-id type, same reason as +/// `resolve_index`: a real multi-entry `WindowId` fixture can't be built in +/// a test, so this is exercised with plain integers standing in for ids. +fn window_owning_tab(tabs_by_window: &[(W, Vec)], tab_id: TabId) -> Option { + tabs_by_window + .iter() + .find(|(_, tabs)| tabs.contains(&tab_id)) + .map(|(wid, _)| *wid) +} + /// Build the `SurfaceRef`/`SurfaceDest` pair for a "move tab" op (keyboard, /// menu, or `ctl move-tab`) from the focused window's active tab. fn build_move_tab( @@ -3619,9 +4571,11 @@ impl Shared { /// The Settings overlay's rows, resolved against the live config. The row /// *table* (labels, kinds, formatters, mutators) lives in `ember-core`; - /// this just asks it to format itself against `self.config`. + /// this just asks it to format itself against `self.config`, plus the + /// live on-disk saved-session count for the "Delete saved sessions (N)…" + /// row's label and hidden-when-zero visibility. pub(crate) fn settings_rows(&self) -> Vec { - resolve_rows(&self.config) + resolve_rows(&self.config, session_state::saved_state_count()) } /// The backdrop params for the current config at animation time `t` seconds. @@ -4042,6 +4996,27 @@ fn bracket_paste(text: &str, bracketed: bool) -> Vec { out } +/// Shape a restored pane's saved command for pre-typing at its (now-ready) +/// prompt: it must land in the shell's edit buffer UNSENT, never executed. +/// +/// Bracketed: `bracket_paste`'s wrap (`ESC[200~`…`ESC[201~`) already produces +/// exactly this — the guarded newlines stay inert inside a shell with +/// bracketed-paste support, so this delegates rather than duplicating the +/// stripping logic. +/// +/// Not bracketed: there is no guard, so a bare `\n` OR `\r` would submit the +/// command immediately (the opposite of "unsent") — a plain `\r` reaches the +/// shell's line editor exactly like Enter, same as `\n`, so both must be +/// flattened. They're replaced with a single space instead — the command +/// still lands in the buffer, just flattened onto one line. +pub(crate) fn pretype_bytes(cmd: &str, bracketed: bool) -> Vec { + if bracketed { + bracket_paste(cmd, true) + } else { + cmd.replace(['\n', '\r'], " ").into_bytes() + } +} + /// Shell-escape one dropped-file path for insertion at a prompt (Finder / /// file-manager drop — iTerm2 parity): a path made only of clearly-safe /// characters passes through untouched; anything else is single-quoted with @@ -4287,9 +5262,12 @@ fn encode_key( #[cfg(test)] mod tests { use super::{ - BELL_FLASH_SECS, DeferredMoveOp, DeferredWindowAction, bell_flash_intensity, bracket_paste, - encode_key, match_tab_title, match_tab_title_across, next_prev_index, queue_close_this, - queue_close_window, resolve_index, shell_escape_path, tab_display_title, url_is_openable, + BELL_FLASH_SECS, DeferredMoveOp, DeferredWindowAction, PaneMeta, SessionId, TabId, + bell_flash_intensity, bracket_paste, clamp_to_visible_monitor, clear_captured_commands, + encode_key, match_tab_title, match_tab_title_across, needs_deferred_replay, + next_prev_index, pane_snap_for, pretype_bytes, queue_close_this, queue_close_window, + resolve_index, resolve_restore_cwd, shell_escape_path, tab_display_title, url_is_openable, + window_owning_tab, }; use winit::keyboard::{Key, ModifiersState, NamedKey, SmolStr}; @@ -4297,6 +5275,261 @@ mod tests { encode_key(&key, mods, false, false) } + // --- clamp_to_visible_monitor (Task 8: restore-position clamp) ----- + + #[test] + fn clamp_leaves_a_position_already_on_a_monitor_untouched() { + let monitors = [(0, 0, 1920, 1080), (1920, 0, 1920, 1080)]; + assert_eq!( + clamp_to_visible_monitor((100, 100), (800, 600), &monitors), + (100, 100) + ); + // On the second monitor too. + assert_eq!( + clamp_to_visible_monitor((2000, 50), (800, 600), &monitors), + (2000, 50) + ); + } + + #[test] + fn clamp_moves_an_offscreen_position_onto_the_nearest_monitor() { + let monitors = [(0, 0, 1920, 1080), (1920, 0, 1920, 1080)]; + // Saved way off to the right of a monitor setup that's since shrunk + // to just the first display: lands on the nearest (only) monitor, + // clamped so the window stays fully on it. + let (x, y) = clamp_to_visible_monitor((5000, 5000), (800, 600), &monitors[..1]); + assert!(x >= 0 && x + 800 <= 1920); + assert!(y >= 0 && y + 600 <= 1080); + } + + #[test] + fn clamp_picks_the_nearer_of_two_monitors() { + let monitors = [(0, 0, 1920, 1080), (10_000, 0, 1920, 1080)]; + // Far off past the SECOND monitor: nearer to it than to the first. + let (x, _y) = clamp_to_visible_monitor((15_000, 0), (800, 600), &monitors); + assert!((10_000..=11_120).contains(&x)); + } + + #[test] + fn clamp_with_no_monitors_leaves_position_unchanged() { + assert_eq!( + clamp_to_visible_monitor((999, 999), (800, 600), &[]), + (999, 999) + ); + } + + // --- resolve_restore_cwd (Task 8: cwd-fallback + pretype eligibility) - + + #[test] + fn resolve_restore_cwd_uses_the_saved_dir_and_command_when_it_still_exists() { + let pane = crate::session_state::PaneSnap { + cwd: Some("/tmp".to_string()), + last_cmd: Some("echo hi".to_string()), + was_running: false, + }; + let (cwd, pretype) = resolve_restore_cwd(&pane); + assert_eq!(cwd.as_deref(), Some("/tmp")); + assert_eq!(pretype.as_deref(), Some("echo hi")); + } + + #[test] + fn resolve_restore_cwd_falls_back_to_home_with_no_pretype_when_the_dir_is_gone() { + let pane = crate::session_state::PaneSnap { + cwd: Some("/this/path/almost-certainly/does-not-exist-12345".to_string()), + last_cmd: Some("echo hi".to_string()), + was_running: false, + }; + let (cwd, pretype) = resolve_restore_cwd(&pane); + assert_eq!( + cwd, + std::env::var_os("HOME").map(|h| h.to_string_lossy().into_owned()) + ); + assert_eq!(pretype, None); + } + + #[test] + fn resolve_restore_cwd_falls_back_to_home_with_no_pretype_when_no_cwd_was_saved() { + let pane = crate::session_state::PaneSnap { + cwd: None, + last_cmd: Some("echo hi".to_string()), + was_running: false, + }; + let (cwd, pretype) = resolve_restore_cwd(&pane); + assert_eq!( + cwd, + std::env::var_os("HOME").map(|h| h.to_string_lossy().into_owned()) + ); + assert_eq!(pretype, None); + } + + // --- needs_deferred_replay (spawn_restored_window's black-pane fix) -- + + #[test] + fn needs_deferred_replay_is_false_when_requested_matches_current() { + assert!(!needs_deferred_replay((800, 600), (800, 600))); + } + + #[test] + fn needs_deferred_replay_is_false_for_a_degenerate_zero_sized_request() { + assert!(!needs_deferred_replay((0, 600), (800, 600))); + assert!(!needs_deferred_replay((800, 0), (800, 600))); + assert!(!needs_deferred_replay((0, 0), (800, 600))); + } + + #[test] + fn needs_deferred_replay_is_true_when_requested_differs_from_current() { + assert!(needs_deferred_replay((1200, 900), (800, 600))); + } + + // --- pane_snap_for / clear_captured_commands (capture-commands-off fix) + + #[test] + fn pane_snap_for_includes_last_cmd_when_capture_is_on() { + let meta = PaneMeta { + cwd: Some("/tmp".to_string()), + last_cmd: Some("echo hi".to_string()), + was_running: true, + }; + let snap = pane_snap_for(Some(&meta), true); + assert_eq!(snap.cwd.as_deref(), Some("/tmp")); + assert_eq!(snap.last_cmd.as_deref(), Some("echo hi")); + assert!(snap.was_running); + } + + #[test] + fn pane_snap_for_drops_last_cmd_when_capture_is_off_even_if_meta_still_has_one() { + // The exact regression: `pane_meta` still holds a command (nothing + // upstream scrubbed it yet) but capture is off — `last_cmd` must + // never reach the snapshot regardless. + let meta = PaneMeta { + cwd: Some("/tmp".to_string()), + last_cmd: Some("echo hi".to_string()), + was_running: true, + }; + let snap = pane_snap_for(Some(&meta), false); + assert_eq!(snap.cwd.as_deref(), Some("/tmp")); + assert_eq!(snap.last_cmd, None); + assert!(snap.was_running); + } + + #[test] + fn pane_snap_for_handles_no_meta_at_all() { + let snap = pane_snap_for(None, true); + assert_eq!(snap.cwd, None); + assert_eq!(snap.last_cmd, None); + assert!(!snap.was_running); + } + + #[test] + fn clear_captured_commands_clears_last_cmd_but_keeps_cwd_and_was_running() { + let mut pane_meta = std::collections::HashMap::new(); + pane_meta.insert( + SessionId::new("s1"), + PaneMeta { + cwd: Some("/a".to_string()), + last_cmd: Some("echo left".to_string()), + was_running: true, + }, + ); + pane_meta.insert( + SessionId::new("s2"), + PaneMeta { + cwd: Some("/b".to_string()), + last_cmd: None, + was_running: false, + }, + ); + clear_captured_commands(&mut pane_meta); + for meta in pane_meta.values() { + assert_eq!(meta.last_cmd, None); + } + assert_eq!(pane_meta[&SessionId::new("s1")].cwd.as_deref(), Some("/a")); + assert!(pane_meta[&SessionId::new("s1")].was_running); + assert_eq!(pane_meta[&SessionId::new("s2")].cwd.as_deref(), Some("/b")); + } + + // --- split-replay acceptance is viewport-size-dependent (Task 8 fix) -- + + /// The pure seam behind `spawn_restored_window`'s size-before-replay + /// fix: `ember_core::apply`'s `SplitPane` acceptance check + /// (`extent >= 2*min_px`) reads the CURRENT viewport, so the exact same + /// sequence of split operations can be refused at one window size and + /// accepted at another. Replaying a saved split tree against the + /// freshly-created window's DEFAULT size (rather than its saved, + /// possibly much larger, size) can therefore silently drop panes that + /// would fit fine once the window is actually resized — which is + /// exactly what `spawn_restored_window` now avoids by applying (or, on + /// async-resize platforms, waiting for) the real size FIRST. + /// + /// This doesn't exercise `WindowState`/`Renderer` at all (both need a + /// live GPU context this test harness doesn't provide — see + /// `settings_action_for_key`'s doc for the same reasoning) — it drives + /// `ember_core::apply` directly with two viewports sized like + /// `WindowState::viewport()` would report for a default-sized window + /// vs. a much larger saved one, using the same MIN_COLS=8 floor + /// `WindowState::min_px` uses. + #[test] + fn split_replay_that_default_window_size_refuses_succeeds_at_the_saved_size() { + use ember_core::{ + Axis, LayoutCommand, LayoutNode, PaneId, Rect, SessionId, Tab, TabId, WindowTree, apply, + }; + + let chrome = ember_render::Renderer::chrome_height() as f64; + let min_px = 8.0 * ember_render::CELL_WIDTH as f64 + 2.0 * crate::PAD as f64; + let default_w = + crate::DEFAULT_COLS as f64 * ember_render::CELL_WIDTH as f64 + 2.0 * crate::PAD as f64; + let default_vp = Rect::new(0.0, chrome, default_w, 400.0 - chrome); + // A saved window several times wider — representative of a maximized + // window on a real monitor, not the app's own small startup default. + let saved_vp = Rect::new(0.0, chrome, 1800.0, 1100.0 - chrome); + + // Four sequential horizontal splits, all targeting the SAME original + // pane (mirrors how `split_ops`' replay can address the same parent + // index repeatedly) — each keeps the original pane at `ratio=0.5` of + // its own current extent, so it roughly halves every time. Returns + // whether all four were accepted. + let run_four_splits = |vp: Rect| -> bool { + let p0 = PaneId(1); + let mut tree = WindowTree { + tabs: vec![Tab { + id: TabId(1), + title: String::new(), + root: LayoutNode::pane(p0, SessionId::new("s0")), + focus: p0, + }], + active: 0, + }; + let mut all_accepted = true; + for i in 0..4u64 { + let effects = apply( + &mut tree, + LayoutCommand::SplitPane { + target: p0, + axis: Axis::Horizontal, + ratio: 0.5, + new_pane: PaneId(100 + i), + new_session: SessionId::new(format!("s{}", 100 + i)), + min_px, + }, + vp, + ); + if effects.is_empty() { + all_accepted = false; + } + } + all_accepted + }; + + assert!( + !run_four_splits(default_vp), + "expected at least one of the 4 splits to be refused at the default window size" + ); + assert!( + run_four_splits(saved_vp), + "the SAME 4 splits must all succeed once replayed against the saved (larger) size" + ); + } + #[test] fn named_editing_and_function_keys_encode() { let n = ModifiersState::empty(); @@ -4440,6 +5673,20 @@ mod tests { assert_eq!(got, b"\x1b[200~arm -rf /\n\x1b[201~".to_vec()); } + #[test] + fn pretype_wraps_or_sanitizes() { + assert_eq!( + pretype_bytes("gt crew at skippy", true), + b"\x1b[200~gt crew at skippy\x1b[201~".to_vec() + ); + assert_eq!(pretype_bytes("a\nb", false), b"a b".to_vec()); + // A bare `\r` reaches the shell's line editor exactly like Enter, + // same as `\n` — it must be flattened too, or a pre-typed command + // could submit itself instead of landing unsent in the buffer. + assert_eq!(pretype_bytes("a\rb", false), b"a b".to_vec()); + assert_eq!(pretype_bytes("a\r\nb", false), b"a b".to_vec()); + } + #[test] fn shell_escape_plain_path_passes_through() { assert_eq!( @@ -4737,4 +5984,31 @@ mod tests { assert_eq!(next_prev_index(1, n, false), 0); assert_eq!(next_prev_index(2, n, false), 1); } + + /// `window_owning_tab` (the search half of the cross-window + /// `named_tabs` carry, Task 5 fix #4): finds the window whose tab list + /// contains the moved id, ignores windows that don't, and returns + /// `None` when nothing does (the merge/`SplitInto` case, where the + /// tab id was dissolved rather than carried into a destination tree). + #[test] + fn window_owning_tab_finds_the_window_holding_the_id() { + let by_window: Vec<(u32, Vec)> = vec![ + (10, vec![TabId(1), TabId(2)]), + (20, vec![TabId(3)]), + (30, vec![]), + ]; + assert_eq!(window_owning_tab(&by_window, TabId(1)), Some(10)); + assert_eq!(window_owning_tab(&by_window, TabId(2)), Some(10)); + assert_eq!(window_owning_tab(&by_window, TabId(3)), Some(20)); + } + + #[test] + fn window_owning_tab_is_none_when_the_id_was_dissolved() { + // Mirrors a merge (`SplitInto`) move: the tab id no longer exists + // as a TAB anywhere (it became a pane inside another tab), so no + // window's tab list contains it -- the carry must be a no-op, not + // a panic or a wrong guess. + let by_window: Vec<(u32, Vec)> = vec![(10, vec![TabId(1)]), (20, vec![TabId(2)])]; + assert_eq!(window_owning_tab(&by_window, TabId(99)), None); + } } diff --git a/crates/ember-app/src/screenshot.rs b/crates/ember-app/src/screenshot.rs index 7f6c6df..b35b3d4 100644 --- a/crates/ember-app/src/screenshot.rs +++ b/crates/ember-app/src/screenshot.rs @@ -40,6 +40,13 @@ pub struct Opts { pub hover_tab: Option, /// Draw a sample "Close this tab?" confirm modal over the panes. pub confirm: bool, + /// Draw the restore-on-launch modal's Main screen over the panes, with + /// representative fixture text (Task 8's own headless-verification + /// hook — see `RestoreView`'s doc for what the two screens show). + pub restore_main: bool, + /// Draw the restore-on-launch modal's `Older…` screen over the panes, + /// with a fixture 3-entry archive list. + pub restore_older: bool, /// Split drop-zone preview on the focused pane: `(horizontal, ratio)`. pub split_preview: Option<(bool, f32)>, /// How long to let the shells produce output before capturing. @@ -104,6 +111,8 @@ impl Default for Opts { tab_drag: None, hover_tab: None, confirm: false, + restore_main: false, + restore_older: false, split_preview: None, settle_ms: 700, backdrop: false, @@ -173,6 +182,8 @@ pub fn parse(args: &[String]) -> Result { opts.hover_tab = Some(next()?.parse().map_err(|e| format!("--hover-tab: {e}"))?) } "--confirm" => opts.confirm = true, + "--restore-main" => opts.restore_main = true, + "--restore-older" => opts.restore_older = true, "--help-overlay" => opts.help_overlay = true, "--settings" => opts.settings = true, "--tab-drag" => { @@ -424,7 +435,9 @@ pub fn run(opts: Opts) -> Result { let mut config = ember_core::Config::default(); config.font.family = opts.font.clone(); config.font.size = opts.font_size; - let rows = ember_core::resolve_rows(&config); + // A headless doc/preview shot has no live session-state dir to + // count, so the "Delete saved sessions" row never appears here. + let rows = ember_core::resolve_rows(&config, 0); let sel = rows .iter() .position(|r| r.label == "Font family") @@ -470,6 +483,24 @@ pub fn run(opts: Opts) -> Result { confirm_label: "Close".to_string(), focused: 0, }), + restore: if opts.restore_main { + Some(ember_render::RestoreView::Main { + header: "Restore 2 windows, 3 tabs (from 2h ago)?".to_string(), + focused: 0, + }) + } else if opts.restore_older { + Some(ember_render::RestoreView::Older { + header: "Restore 2 windows, 3 tabs (from 2h ago)?".to_string(), + rows: vec![ + "2h ago · 2 windows, 3 tabs".to_string(), + "1d ago · 1 window, 1 tab".to_string(), + "3 weeks ago · 3 windows, 5 tabs".to_string(), + ], + selected: 0, + }) + } else { + None + }, hold_ring: opts.hold_ring, // No offline `--screenshot` flag for these (v0.4.0): both are live // cross-window-drag/tear-off previews with no meaningful "just render diff --git a/crates/ember-app/src/session_state.rs b/crates/ember-app/src/session_state.rs new file mode 100644 index 0000000..2abee64 --- /dev/null +++ b/crates/ember-app/src/session_state.rs @@ -0,0 +1,1429 @@ +//! Session snapshot model, atomic writer, and debounce thread. +//! +//! Lives at `$XDG_STATE_HOME/ember/session.json` (else `~/.local/state/ember/session.json`). +//! Defines its own plain serde structs, decoupled from `ember_core`'s in-memory +//! layout types, so the on-disk schema is stable independent of internal +//! refactors. `assemble` maps the live window/tab/split/pane state into that +//! schema; `main.rs`'s `session_dirty` calls it on every structural or +//! content mutation and feeds the result to `SnapshotWriter`, which +//! debounces (300ms quiet, 1s max defer) and writes it atomically so a +//! crash never leaves a corrupt or partial file on disk. +//! +//! Restore-on-launch (reading this file back at startup) is a later task — +//! this module only ever writes it. + +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::Once; +use std::sync::mpsc::{self, RecvTimeoutError, Sender}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +/// Top-level on-disk snapshot: one window list, plus a schema version so a +/// future format change can migrate or discard old files instead of failing +/// to parse. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct SessionSnapshot { + pub version: u32, + pub saved_at: String, + pub windows: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct WindowSnap { + pub pos: Option<(i32, i32)>, + pub size: (u32, u32), + pub focused_tab: usize, + pub tabs: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct TabSnap { + pub name: String, + pub named_by_user: bool, + pub splits: NodeSnap, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub enum NodeSnap { + Pane(PaneSnap), + Split { + dir: char, + ratio: f32, + a: Box, + b: Box, + }, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct PaneSnap { + pub cwd: Option, + pub last_cmd: Option, + pub was_running: bool, +} + +/// Flatten a `NodeSnap` tree into the sequence of split operations that +/// recreates it: `(parent-pane index in creation order, dir, ratio, new +/// pane's PaneSnap)`. The first pane of the tab is index 0 (created with the +/// tab itself); each op creates exactly one more pane, so replaying `ops` in +/// order — `split_pane(pane_by_index[parent], dir, ratio)` — assigns each +/// new pane the NEXT index (1, 2, 3, …) in emission order, matching what +/// this function assumed while building the list. +/// +/// Mirrors [`ember_core`]'s `SplitPane` semantics exactly: a split always +/// targets an existing LEAF pane and replaces it in place with a +/// `Split { a: , b: }` — the target pane +/// keeps its identity (and position, however deeply nested) on the `a` +/// side; only `b` is new. So to grow a pane at index `at` into a whole +/// `Split { a, b }` subtree: first split `at` itself (`dir`/`ratio`, +/// carrying `b`'s own eventual first pane's data as the new pane's content) +/// — this one op reproduces the split node itself, with both sides still +/// plain leaves — then recurse to grow `a` further AT THE SAME INDEX `at` +/// (its identity survives being wrapped), and `b` further at the index the +/// first op just created. Order between the two recursions doesn't matter +/// (independent subtrees after the first op); this walks `a` before `b`. +pub fn split_ops(root: &NodeSnap) -> (PaneSnap, Vec<(usize, char, f32, PaneSnap)>) { + let mut ops = Vec::new(); + let mut next_index = 1usize; // index 0 = the tab's own seed pane + let first = walk_split_ops(root, 0, &mut next_index, &mut ops); + (first, ops) +} + +/// This subtree's leftmost-via-`a` pane's snap, with NO ops emitted — used +/// to peek a fresh split's "new pane" payload before recursing into it for +/// real (see [`split_ops`]'s doc). +fn leftmost_pane(node: &NodeSnap) -> PaneSnap { + match node { + NodeSnap::Pane(p) => p.clone(), + NodeSnap::Split { a, .. } => leftmost_pane(a), + } +} + +/// Recursive helper for [`split_ops`]. `at` is the creation-order index of +/// the pane CURRENTLY occupying this subtree's position (already exists — +/// either the tab's own seed pane, at index 0, or a pane an earlier op just +/// created). Returns this subtree's own first (leftmost-via-`a`) `PaneSnap` +/// — `at`'s eventual identity once every op beneath it has run. +fn walk_split_ops( + node: &NodeSnap, + at: usize, + next_index: &mut usize, + ops: &mut Vec<(usize, char, f32, PaneSnap)>, +) -> PaneSnap { + match node { + NodeSnap::Pane(p) => p.clone(), + NodeSnap::Split { dir, ratio, a, b } => { + let new_index = *next_index; + *next_index += 1; + ops.push((at, *dir, *ratio, leftmost_pane(b))); + let first = walk_split_ops(a, at, next_index, ops); + walk_split_ops(b, new_index, next_index, ops); + first + } + } +} + +/// Outcome of attempting to load a session snapshot from disk. +#[derive(Debug, Clone, PartialEq)] +pub enum LoadOutcome { + /// File does not exist. + None, + /// File exists but is corrupt (not valid JSON, or unparseable); it has + /// been renamed to `session.json.corrupt`. + Corrupt, + /// File was successfully loaded and parsed. + Loaded(SessionSnapshot), +} + +/// One entry from `list_archives`: a snapshot file, its timestamp from the +/// filename, and metadata (window/tab counts) extracted during listing. +#[derive(Debug, Clone, PartialEq)] +pub struct ArchiveEntry { + pub path: PathBuf, + pub stamp: String, + pub windows: usize, + pub tabs: usize, +} + +/// Maximum number of archived snapshots to keep. Older archives are pruned +/// when this limit is exceeded. +pub const MAX_ARCHIVES: usize = 10; + +/// Truncate `s` to at most 1024 bytes, backing off to the nearest earlier +/// char boundary so a multi-byte UTF-8 character is never split. The single +/// enforcement point for the on-disk `last_cmd` size cap — everything else +/// that stores a command string (`Shared::pane_meta`, `PaneSnap`) is +/// unbounded, and relies on `assemble` calling this on the way out. +fn cap_cmd(s: String) -> String { + if s.len() <= 1024 { + return s; + } + let mut end = 1024; + while !s.is_char_boundary(end) { + end -= 1; + } + s[..end].to_string() +} + +/// Map one `LayoutNode` subtree into its on-disk `NodeSnap` shape, filling +/// each leaf's live pane data via `meta` (a session id -> `PaneSnap` lookup +/// the caller closes over its own live state with). `last_cmd` is capped +/// here (see `cap_cmd`) so every path that can produce a `PaneSnap` — real +/// wiring and this recursive walk alike — goes through the one truncation +/// point. +fn assemble_node( + node: &ember_core::layout::LayoutNode, + meta: &dyn Fn(&ember_core::ids::SessionId) -> PaneSnap, +) -> NodeSnap { + use ember_core::layout::LayoutNode; + match node { + LayoutNode::Pane { session, .. } => { + let mut pane = meta(session); + pane.last_cmd = pane.last_cmd.map(cap_cmd); + NodeSnap::Pane(pane) + } + LayoutNode::Split { axis, ratio, a, b } => NodeSnap::Split { + dir: match axis { + ember_core::layout::Axis::Horizontal => 'h', + ember_core::layout::Axis::Vertical => 'v', + }, + ratio: *ratio as f32, + a: Box::new(assemble_node(a, meta)), + b: Box::new(assemble_node(b, meta)), + }, + } +} + +/// One window's live geometry + tree + per-tab `named_by_user` flags, as +/// `assemble` wants them: `(outer position, inner size, tree, named-by-user +/// flags parallel to `tree.tabs`)`. A named alias purely so the tuple isn't +/// spelled out (and clippy's `type_complexity` tripped) at every use site — +/// the tuple shape itself is the real, documented interface. +pub type WindowInput<'a> = ( + Option<(i32, i32)>, + (u32, u32), + &'a ember_core::layout::WindowTree, + &'a [bool], +); + +/// Pure assembly: map the live per-window `WindowTree`s (plus each window's +/// `(pos, size)` and per-tab `named_by_user` flags, all already extracted by +/// the caller from winit/`WindowState`) and each pane's live metadata +/// (`meta`, which the wiring layer closes over `Shared::pane_meta`) into the +/// on-disk `SessionSnapshot` shape. No I/O and no dependency on `Shared` or +/// `WindowState`, so it's exhaustively unit-testable here; the impure half +/// (reading winit/`Shared` state, deciding when to call this) lives in +/// `main.rs`'s `session_dirty`. +/// +/// `windows[i].3` (the `named_by_user` slice) is indexed in parallel with +/// `windows[i].2.tabs` — a short slice (fewer entries than tabs) treats the +/// missing tail as `false` rather than panicking, so a caller can never +/// crash the app by passing a stale-length flags slice. +pub fn assemble( + windows: &[WindowInput], + meta: &dyn Fn(&ember_core::ids::SessionId) -> PaneSnap, +) -> SessionSnapshot { + let windows = windows + .iter() + .map(|(pos, size, tree, named_by_user)| WindowSnap { + pos: *pos, + size: *size, + focused_tab: tree.active, + tabs: tree + .tabs + .iter() + .enumerate() + .map(|(i, tab)| TabSnap { + name: tab.title.clone(), + named_by_user: named_by_user.get(i).copied().unwrap_or(false), + splits: assemble_node(&tab.root, meta), + }) + .collect(), + }) + .collect(); + let saved_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs().to_string()) + .unwrap_or_default(); + SessionSnapshot { + version: 1, + saved_at, + windows, + } +} + +/// The session-state file path, if a home/state dir can be determined. +/// Mirrors `config::path()`'s XDG shape. +pub fn state_path() -> Option { + if let Some(xdg) = std::env::var_os("XDG_STATE_HOME").filter(|s| !s.is_empty()) { + return Some(PathBuf::from(xdg).join("ember/session.json")); + } + std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state/ember/session.json")) +} + +/// Write `snap` to `path` atomically: serialize to a same-directory temp +/// file (mode 0600 on unix), fsync it, then rename over the target. A +/// reader never observes a partial or torn write. +pub fn write_atomic(path: &Path, snap: &SessionSnapshot) -> io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let tmp_path = path.with_extension("json.tmp"); + let bytes = serde_json::to_vec_pretty(snap)?; + + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let mut file = opts.open(&tmp_path)?; + + use std::io::Write; + file.write_all(&bytes)?; + file.sync_all()?; + drop(file); + + std::fs::rename(&tmp_path, path)?; + Ok(()) +} + +/// The filesystem footprint the Settings "Delete saved sessions" action +/// covers, colocated next to `path` (`state_path()`'s result): the live +/// snapshot itself, its quarantined-corrupt sibling (`session.json.corrupt` +/// — Task 7's quarantine path writes it), and every archived +/// `session.json.prev-*` snapshot (Task 7's `archive`). Only entries that +/// actually exist are returned. Shared by the read-only `saved_state_count` +/// (the Settings row's live count) and the destructive `delete_all_state`, +/// so the two can never disagree about what's there. +fn state_footprint(path: &Path) -> Vec { + let mut found = Vec::new(); + if path.is_file() { + found.push(path.to_path_buf()); + } + let corrupt = path.with_extension("json.corrupt"); + if corrupt.is_file() { + found.push(corrupt); + } + if let Some(dir) = path.parent() { + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + if entry + .file_name() + .to_string_lossy() + .starts_with("session.json.prev-") + { + found.push(entry.path()); + } + } + } + } + found +} + +/// Count of files the "Delete saved sessions" Settings action would remove, +/// without touching the disk — what the row's `(N)` label and its +/// hidden-when-`0` visibility read. `0` when no state path can be resolved. +pub fn saved_state_count() -> usize { + state_path().map(|p| state_footprint(&p).len()).unwrap_or(0) +} + +/// Delete every on-disk session-state file (see `state_footprint`): the +/// live snapshot, its quarantined-corrupt sibling, and every +/// `session.json.prev-*` archive. Best-effort — a removal failure for one +/// file doesn't stop the others. Returns the count actually removed (`0` +/// when no state path can be resolved or nothing exists), which the +/// Settings row uses to refresh its own count immediately after. +pub fn delete_all_state() -> usize { + let Some(path) = state_path() else { + return 0; + }; + state_footprint(&path) + .into_iter() + .filter(|p| std::fs::remove_file(p).is_ok()) + .count() +} + +/// Clear every pane's `last_cmd` to `None` in the split tree, in place. +fn strip_node_commands(node: &mut NodeSnap) { + match node { + NodeSnap::Pane(p) => p.last_cmd = None, + NodeSnap::Split { a, b, .. } => { + strip_node_commands(a); + strip_node_commands(b); + } + } +} + +/// Immediately rewrite the state file at `path` with every pane's +/// `last_cmd` cleared — the "Capture commands" off-switch's immediate-strip +/// ruling (privacy: turning capture off also scrubs what's already on +/// disk, not just what gets written next). Load, map, `write_atomic`; a +/// missing file is a no-op (nothing to strip), and an unparseable one is +/// left alone (Task 7's quarantine path owns corrupt files, not this one). +pub fn strip_commands(path: &Path) -> io::Result<()> { + let text = match std::fs::read_to_string(path) { + Ok(t) => t, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(e), + }; + let Ok(mut snap) = serde_json::from_str::(&text) else { + return Ok(()); + }; + for w in &mut snap.windows { + for t in &mut w.tabs { + strip_node_commands(&mut t.splits); + } + } + write_atomic(path, &snap) +} + +/// Load and parse a session snapshot from `path`. Returns: +/// - `LoadOutcome::None` if the file does not exist or has an unsupported version. +/// - `LoadOutcome::Corrupt` if the file exists but is not valid JSON; the +/// file is renamed to `session.json.corrupt` (overwriting any older +/// corrupt file). +/// - `LoadOutcome::Loaded(snap)` if the file was successfully parsed. +pub fn load(path: &Path) -> LoadOutcome { + let text = match std::fs::read_to_string(path) { + Ok(t) => t, + Err(e) if e.kind() == io::ErrorKind::NotFound => return LoadOutcome::None, + Err(_) => return LoadOutcome::None, + }; + + match serde_json::from_str::(&text) { + Ok(snap) => { + if snap.version != 1 { + // Unknown version: leave the file alone (it might be from a + // future version we can't parse yet). + return LoadOutcome::None; + } + LoadOutcome::Loaded(snap) + } + Err(_) => { + // Corrupt JSON: rename to quarantine and return Corrupt. + let corrupt_path = path.with_extension("json.corrupt"); + if let Err(e) = std::fs::rename(path, &corrupt_path) { + log_write_failure_once(&e); + } + LoadOutcome::Corrupt + } + } +} + +/// Format a timestamp as YYYYMMDD-HHMMSS from the given SystemTime. +/// Returns an empty string if the time cannot be formatted. +fn format_timestamp(time: SystemTime) -> String { + let duration = match time.duration_since(UNIX_EPOCH) { + Ok(d) => d, + Err(_) => return String::new(), + }; + let secs = duration.as_secs(); + + // Compute YYYYMMDD-HHMMSS from seconds since epoch. + // This is a simplified calculation that doesn't account for leap seconds. + const SECS_PER_DAY: u64 = 86400; + const SECS_PER_HOUR: u64 = 3600; + const SECS_PER_MINUTE: u64 = 60; + + // Convert seconds to days, hours, minutes, seconds + let days_since_epoch = secs / SECS_PER_DAY; + let secs_in_day = secs % SECS_PER_DAY; + let hours = secs_in_day / SECS_PER_HOUR; + let secs_in_hour = secs_in_day % SECS_PER_HOUR; + let minutes = secs_in_hour / SECS_PER_MINUTE; + let seconds = secs_in_hour % SECS_PER_MINUTE; + + // Days since Jan 1, 1970 to a calendar date (simplified). + // This is approximate but sufficient for archive timestamps. + let mut year = 1970; + let mut days = days_since_epoch; + loop { + let days_in_year = if is_leap_year(year) { 366 } else { 365 }; + if days < days_in_year { + break; + } + days -= days_in_year; + year += 1; + } + + let mut month = 1; + let mut day = days + 1; + for m in 1..=12 { + let days_in_month = match m { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 => { + if is_leap_year(year) { + 29 + } else { + 28 + } + } + _ => 0, + }; + if day <= days_in_month { + month = m; + break; + } + day -= days_in_month; + } + + format!( + "{:04}{:02}{:02}-{:02}{:02}{:02}", + year, month, day, hours, minutes, seconds + ) +} + +fn is_leap_year(year: u64) -> bool { + (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) +} + +/// Inverse of `format_timestamp`: parse a `YYYYMMDD-HHMMSS` stamp back into +/// a `SystemTime`. Only the first 15 bytes are read, so an +/// `archive_with_stamp` collision suffix (`-2`, `-3`, …) appended after the +/// base stamp is silently ignored rather than rejected. `None` on anything +/// too short or non-numeric (a hand-edited or truncated filename) — the +/// restore modal falls back to an empty age string rather than panicking on +/// a malformed archive name. +fn parse_timestamp(stamp: &str) -> Option { + let core = stamp.get(0..15)?; + let (date, rest) = core.split_at(8); + let time = rest.strip_prefix('-')?; + let year: u64 = date.get(0..4)?.parse().ok()?; + let month: u64 = date.get(4..6)?.parse().ok()?; + let day: u64 = date.get(6..8)?.parse().ok()?; + let hour: u64 = time.get(0..2)?.parse().ok()?; + let minute: u64 = time.get(2..4)?.parse().ok()?; + let second: u64 = time.get(4..6)?.parse().ok()?; + if !(1..=12).contains(&month) || !(1..=31).contains(&day) || hour > 23 || minute > 59 { + return None; + } + let mut days: u64 = 0; + for y in 1970..year { + days += if is_leap_year(y) { 366 } else { 365 }; + } + for m in 1..month { + days += match m { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 => { + if is_leap_year(year) { + 29 + } else { + 28 + } + } + _ => 0, + }; + } + days += day - 1; + let secs = days * SECS_PER_DAY + hour * 3600 + minute * 60 + second; + Some(UNIX_EPOCH + Duration::from_secs(secs)) +} + +const SECS_PER_DAY: u64 = 86400; + +/// Humanize an elapsed duration (seconds) into a short, casual age label — +/// the restore modal's own scale, not a calendar-accurate diff: "just now", +/// "5m ago", "2h ago", "3d ago", "3 weeks ago", "2 months ago", "1 year +/// ago". A negative (clock-skew) or unparseable elapsed time also reads as +/// "just now" rather than a confusing negative duration. +fn humanize_duration(secs: i64) -> String { + if secs < 60 { + return "just now".to_string(); + } + let secs = secs as u64; + if secs < 3600 { + return format!("{}m ago", secs / 60); + } + if secs < SECS_PER_DAY { + return format!("{}h ago", secs / 3600); + } + if secs < SECS_PER_DAY * 7 { + return format!("{}d ago", secs / SECS_PER_DAY); + } + if secs < SECS_PER_DAY * 30 { + let w = secs / (SECS_PER_DAY * 7); + return format!("{w} week{} ago", if w == 1 { "" } else { "s" }); + } + if secs < SECS_PER_DAY * 365 { + let mo = secs / (SECS_PER_DAY * 30); + return format!("{mo} month{} ago", if mo == 1 { "" } else { "s" }); + } + let y = secs / (SECS_PER_DAY * 365); + format!("{y} year{} ago", if y == 1 { "" } else { "s" }) +} + +/// Humanize a `SessionSnapshot::saved_at` (unix-seconds-as-string) relative +/// to `now`. An unparseable/missing `saved_at` (a hand-edited or ancient +/// snapshot) reads as "just now" — a stale-looking timestamp would be +/// actively misleading in the restore prompt, and "just now" is the neutral +/// fallback the humanized scale already provides. +pub fn humanize_age(saved_at: &str, now: SystemTime) -> String { + let Ok(secs) = saved_at.parse::() else { + return "just now".to_string(); + }; + let saved = UNIX_EPOCH + Duration::from_secs(secs); + let elapsed = now + .duration_since(saved) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + humanize_duration(elapsed) +} + +/// Humanize an archive's `YYYYMMDD-HHMMSS` filename stamp relative to `now` +/// (the `Older…` list's per-row age) — same fallback-to-"just now" ruling +/// as `humanize_age` for a stamp that fails to parse. +pub fn humanize_stamp(stamp: &str, now: SystemTime) -> String { + let Some(saved) = parse_timestamp(stamp) else { + return "just now".to_string(); + }; + let elapsed = now + .duration_since(saved) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + humanize_duration(elapsed) +} + +/// Archive the snapshot at `path` by renaming it to +/// `session.json.prev-`. Then prune old archives, keeping +/// only the `MAX_ARCHIVES` newest by filename (which sorts by timestamp). +pub fn archive(path: &Path) -> io::Result<()> { + let stamp = format_timestamp(SystemTime::now()); + archive_with_stamp(path, &stamp) +} + +/// Archive with an explicit stamp (for testing). Renames `path` to +/// `session.json.prev-`, then prunes old archives to keep only +/// the `MAX_ARCHIVES` newest by timestamp. If a file with that stamp +/// already exists, appends a numeric suffix (`-2`, `-3`, etc.) to avoid +/// collisions; the suffixed name still sorts lexicographically as newer. +pub fn archive_with_stamp(path: &Path, stamp: &str) -> io::Result<()> { + if !path.exists() { + return Ok(()); + } + + // Rename to archive, with collision avoidance via numeric suffix + let Some(dir) = path.parent() else { + return Ok(()); + }; + let mut archive_path = dir.join(format!("session.json.prev-{}", stamp)); + let mut suffix = 2; + while archive_path.exists() { + archive_path = dir.join(format!("session.json.prev-{}-{}", stamp, suffix)); + suffix += 1; + } + std::fs::rename(path, archive_path)?; + + // Prune old archives + prune_archives(dir)?; + Ok(()) +} + +/// Prune archived snapshots in `dir` to keep only the `MAX_ARCHIVES` newest. +fn prune_archives(dir: &Path) -> io::Result<()> { + let mut archives = match std::fs::read_dir(dir) { + Ok(entries) => { + let mut found: Vec = entries + .flatten() + .filter_map(|entry| { + let path = entry.path(); + if path + .file_name()? + .to_string_lossy() + .starts_with("session.json.prev-") + { + Some(path) + } else { + None + } + }) + .collect(); + found.sort_by(|a, b| b.cmp(a)); // Sort newest first + found + } + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(e), + }; + + // Remove oldest archives beyond MAX_ARCHIVES + while archives.len() > MAX_ARCHIVES { + if let Some(path) = archives.pop() { + let _ = std::fs::remove_file(path); + } + } + Ok(()) +} + +/// List all archived snapshots in `dir`, returning the newest first. +/// Each entry contains the path, extracted timestamp, window count, and tab count. +/// Unparseable files are skipped. +pub fn list_archives(dir: &Path) -> Vec { + let mut entries: Vec = match std::fs::read_dir(dir) { + Ok(dir_entries) => { + dir_entries + .flatten() + .filter_map(|entry| { + let path = entry.path(); + let file_name = path.file_name()?.to_string_lossy().to_string(); + if !file_name.starts_with("session.json.prev-") { + return None; + } + + // Extract stamp from filename + let stamp = file_name + .strip_prefix("session.json.prev-") + .unwrap_or("") + .to_string(); + + // Read and parse the file to count windows and tabs + let text = std::fs::read_to_string(&path).ok()?; + let snap: SessionSnapshot = serde_json::from_str(&text).ok()?; + + let mut total_tabs = 0; + for window in &snap.windows { + total_tabs += window.tabs.len(); + } + + Some(ArchiveEntry { + path, + stamp, + windows: snap.windows.len(), + tabs: total_tabs, + }) + }) + .collect() + } + Err(_) => return Vec::new(), + }; + + entries.sort_by(|a, b| b.stamp.cmp(&a.stamp)); // Sort newest first + entries +} + +/// Read and parse one archived snapshot for restoring — unlike `load`, this +/// never quarantines a bad file (an archive is disposable/read-only history, +/// not the live state `load`'s corruption handling protects) and never +/// checks `version` (an archive this old process can't parse is simply +/// unusable here). `None` on any read or parse failure. +pub fn load_archive(path: &Path) -> Option { + let text = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&text).ok() +} + +static WRITE_FAILURE_LOGGED: Once = Once::new(); + +/// Log a write failure exactly once for the process lifetime, then swallow +/// it. The write policy: never panic, never block a caller; the next update +/// retries naturally. +fn log_write_failure_once(e: &io::Error) { + WRITE_FAILURE_LOGGED.call_once(|| { + eprintln!("[ember] session snapshot write failed: {e}"); + }); +} + +/// Owns the writer thread. Dropping the returned `SnapshotHandle` stops it +/// (after flushing any pending snapshot). +pub struct SnapshotWriter; + +impl SnapshotWriter { + /// Spawn the debounce/writer thread for `path` and return a handle to + /// feed it snapshots. The thread sleeps indefinitely when idle: it never + /// wakes on a timer while nothing is pending. + pub fn spawn(path: PathBuf) -> SnapshotHandle { + let (tx, rx) = mpsc::channel::(); + let join = std::thread::spawn(move || { + // Writer thread: 300ms quiet, 1s max defer, Drop flushes. + let mut pending: Option = None; + let mut first_dirty: Option = None; + loop { + let timeout = match first_dirty { + None => Duration::from_secs(3600), // idle: sleep until a message + Some(t0) => { + let quiet_deadline = Duration::from_millis(300); + let cap_deadline = Duration::from_secs(1).saturating_sub(t0.elapsed()); + quiet_deadline.min(cap_deadline) + } + }; + match rx.recv_timeout(timeout) { + Ok(snap) => { + pending = Some(snap); + first_dirty.get_or_insert_with(Instant::now); + } + Err(RecvTimeoutError::Timeout) => { + if let Some(s) = pending.take() { + if let Err(e) = write_atomic(&path, &s) { + log_write_failure_once(&e); + } + } + first_dirty = None; + } + Err(RecvTimeoutError::Disconnected) => { + if let Some(s) = pending.take() { + if let Err(e) = write_atomic(&path, &s) { + log_write_failure_once(&e); + } + } + break; + } + } + } + }); + SnapshotHandle { + tx: Some(tx), + join: Some(join), + } + } +} + +/// A handle to a running [`SnapshotWriter`] thread. `update()` feeds it a +/// new snapshot (debounced, never blocking the caller); dropping the handle +/// flushes any pending snapshot synchronously before returning. +pub struct SnapshotHandle { + tx: Option>, + join: Option>, +} + +impl SnapshotHandle { + /// Queue a new snapshot. Never blocks; a full mailbox slot or a dead + /// writer thread is silently ignored (the caller must never stall on + /// this). + pub fn update(&self, snap: SessionSnapshot) { + if let Some(tx) = &self.tx { + let _ = tx.send(snap); + } + } +} + +impl Drop for SnapshotHandle { + fn drop(&mut self) { + // Drop the sender first so the writer thread's `recv_timeout` sees + // `Disconnected`, flushes any pending snapshot, and exits; then join + // so that flush has completed before we return. + self.tx.take(); + if let Some(join) = self.join.take() { + let _ = join.join(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn snap(marker: &str) -> SessionSnapshot { + SessionSnapshot { + version: 1, + saved_at: marker.into(), + windows: vec![], + } + } + fn pane(cwd: &str) -> PaneSnap { + PaneSnap { + cwd: Some(cwd.to_string()), + last_cmd: None, + was_running: false, + } + } + + #[test] + fn split_ops_recreates_a_nested_tree() { + // h-split whose left is a v-split: ops must be executable in order. + let tree = NodeSnap::Split { + dir: 'h', + ratio: 0.6, + a: Box::new(NodeSnap::Split { + dir: 'v', + ratio: 0.5, + a: Box::new(NodeSnap::Pane(pane("p0"))), + b: Box::new(NodeSnap::Pane(pane("p1"))), + }), + b: Box::new(NodeSnap::Pane(pane("p2"))), + }; + let (first, ops) = split_ops(&tree); + assert_eq!(first.cwd.as_deref(), Some("p0")); + assert_eq!(ops.len(), 2); + // op order must split the root h first (p2 off p0), then v (p1 off p0), + // OR any order that yields the same final tree — assert the invariant: + assert_eq!(ops.iter().filter(|o| o.1 == 'h').count(), 1); + assert_eq!(ops.iter().filter(|o| o.1 == 'v').count(), 1); + } + + #[test] + fn split_ops_on_a_bare_pane_is_the_seed_with_no_ops() { + let (first, ops) = split_ops(&NodeSnap::Pane(pane("only"))); + assert_eq!(first.cwd.as_deref(), Some("only")); + assert!(ops.is_empty()); + } + + /// Every op's parent index must be resolvable by replaying ops in order + /// against a flat `pane_by_index` vec seeded with just the first pane — + /// exactly how `main.rs`'s `spawn_restored` walks them. This simulates + /// that replay purely (no real split_pane/tree) and checks the resulting + /// parent/child pane-content adjacency matches a hand-built expectation + /// for a 3-level-deep tree (more ops than the 2-op nested-tree test). + #[test] + fn split_ops_replay_indices_resolve_for_a_three_level_tree() { + // ((p0 | p1) over p2) beside p3: h( v( h(p0,p1), p2 ), p3 ) + let tree = NodeSnap::Split { + dir: 'h', + ratio: 0.5, + a: Box::new(NodeSnap::Split { + dir: 'v', + ratio: 0.5, + a: Box::new(NodeSnap::Split { + dir: 'h', + ratio: 0.5, + a: Box::new(NodeSnap::Pane(pane("p0"))), + b: Box::new(NodeSnap::Pane(pane("p1"))), + }), + b: Box::new(NodeSnap::Pane(pane("p2"))), + }), + b: Box::new(NodeSnap::Pane(pane("p3"))), + }; + let (first, ops) = split_ops(&tree); + assert_eq!(first.cwd.as_deref(), Some("p0")); + assert_eq!(ops.len(), 3); + // Replay: pane_by_index[0] = first; each op's parent index must + // already exist in the vec built so far. + let mut pane_by_index = vec![first.cwd.clone()]; + for (parent, _dir, _ratio, new_pane) in &ops { + assert!( + *parent < pane_by_index.len(), + "op parent index {parent} not yet created" + ); + pane_by_index.push(new_pane.cwd.clone()); + } + // Every original pane's cwd must appear exactly once across the + // replay (the seed + one per op). + let mut cwds: Vec> = pane_by_index; + cwds.sort(); + let mut expected = vec![ + Some("p0".to_string()), + Some("p1".to_string()), + Some("p2".to_string()), + Some("p3".to_string()), + ]; + expected.sort(); + assert_eq!(cwds, expected); + } + + // --- humanize_age / humanize_stamp / parse_timestamp ----- + + #[test] + fn humanize_age_buckets() { + let now = UNIX_EPOCH + Duration::from_secs(3_000_000_000); + let saved = |ago: u64| { + (now - Duration::from_secs(ago)) + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + .to_string() + }; + assert_eq!(humanize_age(&saved(5), now), "just now"); + assert_eq!(humanize_age(&saved(60 * 5), now), "5m ago"); + assert_eq!(humanize_age(&saved(3600 * 2), now), "2h ago"); + assert_eq!(humanize_age(&saved(86400 * 3), now), "3d ago"); + assert_eq!(humanize_age(&saved(86400 * 7 * 3), now), "3 weeks ago"); + assert_eq!(humanize_age(&saved(86400 * 30), now), "1 month ago"); + assert_eq!(humanize_age(&saved(86400 * 365 * 2), now), "2 years ago"); + assert_eq!(humanize_age("not-a-number", now), "just now"); + } + + #[test] + fn parse_timestamp_round_trips_format_timestamp() { + let t = UNIX_EPOCH + Duration::from_secs(1_800_000_000); + let stamp = format_timestamp(t); + let parsed = parse_timestamp(&stamp).unwrap(); + // Round-trips to the second (format_timestamp truncates to seconds). + assert_eq!( + parsed.duration_since(UNIX_EPOCH).unwrap().as_secs(), + t.duration_since(UNIX_EPOCH).unwrap().as_secs() + ); + } + + #[test] + fn parse_timestamp_ignores_a_collision_suffix() { + let t = UNIX_EPOCH + Duration::from_secs(1_800_000_000); + let stamp = format!("{}-2", format_timestamp(t)); + let parsed = parse_timestamp(&stamp).unwrap(); + assert_eq!( + parsed.duration_since(UNIX_EPOCH).unwrap().as_secs(), + t.duration_since(UNIX_EPOCH).unwrap().as_secs() + ); + } + + #[test] + fn parse_timestamp_rejects_garbage() { + assert!(parse_timestamp("not-a-stamp").is_none()); + assert!(parse_timestamp("").is_none()); + } + + #[test] + fn humanize_stamp_matches_humanize_age_for_the_same_instant() { + let now = UNIX_EPOCH + Duration::from_secs(2_000_000_000); + let saved = now - Duration::from_secs(3600 * 5); + let stamp = format_timestamp(saved); + assert_eq!(humanize_stamp(&stamp, now), "5h ago"); + } + + // --- load_archive ----- + + #[test] + fn load_archive_reads_a_valid_file_and_none_for_missing_or_bad() { + let p = tmp("load-archive"); + write_atomic(&p, &snap("archived")).unwrap(); + let loaded = load_archive(&p).unwrap(); + assert_eq!(loaded.saved_at, "archived"); + assert!(load_archive(&p.with_file_name("nope.json")).is_none()); + let bad = p.with_file_name("bad.json"); + std::fs::write(&bad, b"not json").unwrap(); + assert!(load_archive(&bad).is_none()); + } + + fn tmp(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("ember-ss-{}-{}", name, std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d.join("session.json") + } + + #[test] + fn atomic_write_roundtrips_with_0600() { + let p = tmp("rt"); + write_atomic(&p, &snap("t1")).unwrap(); + let loaded: SessionSnapshot = + serde_json::from_str(&std::fs::read_to_string(&p).unwrap()).unwrap(); + assert_eq!(loaded.saved_at, "t1"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } + + #[test] + fn writer_debounces_and_writes_last_value() { + let p = tmp("debounce"); + let h = SnapshotWriter::spawn(p.clone()); + for i in 0..20 { + h.update(snap(&format!("v{i}"))); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + // 20 updates over ~200ms: the 1s cap OR the 300ms quiet after the last + // update must have produced a file containing the LAST value by now. + std::thread::sleep(std::time::Duration::from_millis(600)); + let loaded: SessionSnapshot = + serde_json::from_str(&std::fs::read_to_string(&p).unwrap()).unwrap(); + assert_eq!(loaded.saved_at, "v19"); + drop(h); + } + + #[test] + fn drop_flushes_pending() { + let p = tmp("flush"); + let h = SnapshotWriter::spawn(p.clone()); + h.update(snap("final")); + drop(h); // no sleep: Drop must flush synchronously + let loaded: SessionSnapshot = + serde_json::from_str(&std::fs::read_to_string(&p).unwrap()).unwrap(); + assert_eq!(loaded.saved_at, "final"); + } + + #[test] + fn assemble_maps_tree_and_meta() { + use ember_core::{ + ids::{PaneId, SessionId, TabId}, + layout::{Axis, LayoutNode, Tab, WindowTree}, + }; + let tree = WindowTree { + active: 0, + tabs: vec![Tab { + id: TabId(1), + title: "EA".into(), + focus: PaneId(1), + root: LayoutNode::split( + Axis::Horizontal, + 0.5, + LayoutNode::pane(PaneId(1), SessionId::new("s1")), + LayoutNode::pane(PaneId(2), SessionId::new("s2")), + ), + }], + }; + let snap = assemble( + &[(Some((10, 20)), (800, 600), &tree, &[true])], + &|sid: &SessionId| PaneSnap { + cwd: Some(format!("/d/{}", sid.0)), + last_cmd: (sid.0 == "s1").then(|| "gt crew at skippy".into()), + was_running: sid.0 == "s1", + }, + ); + assert_eq!(snap.windows.len(), 1); + assert_eq!(snap.windows[0].pos, Some((10, 20))); + assert_eq!(snap.windows[0].size, (800, 600)); + let tab = &snap.windows[0].tabs[0]; + assert!(tab.named_by_user); + let NodeSnap::Split { dir, a, .. } = &tab.splits else { + panic!("expected split") + }; + assert_eq!(*dir, 'h'); + let NodeSnap::Pane(p) = a.as_ref() else { + panic!("expected pane") + }; + assert_eq!(p.last_cmd.as_deref(), Some("gt crew at skippy")); + } + + #[test] + fn assemble_caps_last_cmd_at_1024_on_a_char_boundary() { + use ember_core::ids::{PaneId, SessionId, TabId}; + use ember_core::layout::{LayoutNode, Tab, WindowTree}; + // A multi-byte char (3 bytes each) straddling the 1024 cutoff so a + // byte-oblivious truncation would split it. + let long: String = "€".repeat(400); // 1200 bytes + let tree = WindowTree { + active: 0, + tabs: vec![Tab { + id: TabId(1), + title: String::new(), + focus: PaneId(1), + root: LayoutNode::pane(PaneId(1), SessionId::new("s1")), + }], + }; + let snap = assemble(&[(None, (100, 100), &tree, &[false])], &|_sid| PaneSnap { + cwd: None, + last_cmd: Some(long.clone()), + was_running: false, + }); + let NodeSnap::Pane(p) = &snap.windows[0].tabs[0].splits else { + panic!("expected pane") + }; + let capped = p.last_cmd.as_deref().unwrap(); + assert!(capped.len() <= 1024); + assert!(long.starts_with(capped)); + // Must land ON a char boundary, not mid-character. + assert!(long.is_char_boundary(capped.len())); + } + + // --- strip_commands: the "Capture commands" off immediate-strip ------- + + fn snap_with_commands() -> SessionSnapshot { + SessionSnapshot { + version: 1, + saved_at: "t1".into(), + windows: vec![WindowSnap { + pos: Some((1, 2)), + size: (80, 24), + focused_tab: 0, + tabs: vec![TabSnap { + name: "tab".into(), + named_by_user: true, + splits: NodeSnap::Split { + dir: 'h', + ratio: 0.5, + a: Box::new(NodeSnap::Pane(PaneSnap { + cwd: Some("/a".into()), + last_cmd: Some("echo left".into()), + was_running: true, + })), + b: Box::new(NodeSnap::Pane(PaneSnap { + cwd: Some("/b".into()), + last_cmd: Some("echo right".into()), + was_running: false, + })), + }, + }], + }], + } + } + + #[test] + fn strip_commands_clears_last_cmd_but_keeps_everything_else() { + let p = tmp("strip"); + write_atomic(&p, &snap_with_commands()).unwrap(); + strip_commands(&p).unwrap(); + let loaded: SessionSnapshot = + serde_json::from_str(&std::fs::read_to_string(&p).unwrap()).unwrap(); + let NodeSnap::Split { a, b, dir, ratio } = &loaded.windows[0].tabs[0].splits else { + panic!("expected split") + }; + let NodeSnap::Pane(pa) = a.as_ref() else { + panic!("expected pane") + }; + let NodeSnap::Pane(pb) = b.as_ref() else { + panic!("expected pane") + }; + assert_eq!(pa.last_cmd, None); + assert_eq!(pb.last_cmd, None); + // Everything else survives untouched. + assert_eq!(*dir, 'h'); + assert_eq!(*ratio, 0.5); + assert_eq!(pa.cwd.as_deref(), Some("/a")); + assert!(pa.was_running); + assert_eq!(pb.cwd.as_deref(), Some("/b")); + assert!(!pb.was_running); + assert!(loaded.windows[0].tabs[0].named_by_user); + } + + /// The full "Capture commands off must actually keep commands off disk" + /// regression, not just the on-disk strip: `strip_commands` above only + /// rewrites the FILE as it stands the instant capture is toggled off. + /// The real bug was that the next `session_dirty` reassembled from + /// `Shared::pane_meta` — which the strip never touches — and silently + /// wrote every live command straight back on top of the file that was + /// just stripped. This exercises the other two legs of the fix against + /// the real `assemble` pure function: `crate::clear_captured_commands` + /// scrubbing pane metadata that (like the bug) still has a command sitting + /// in it, and `crate::pane_snap_for` gating `last_cmd` on + /// `capture_commands` as a second, independent line of defense — so a + /// subsequent assemble carries no commands whether or not the metadata + /// was scrubbed in time. + #[test] + fn subsequent_assemble_carries_no_commands_once_capture_is_off() { + use ember_core::ids::{PaneId, SessionId, TabId}; + use ember_core::layout::{LayoutNode, Tab, WindowTree}; + + let p = tmp("strip-then-reassemble"); + write_atomic(&p, &snap_with_commands()).unwrap(); + strip_commands(&p).unwrap(); + + let sid = SessionId::new("s1"); + let tree = WindowTree { + active: 0, + tabs: vec![Tab { + id: TabId(1), + title: String::new(), + focus: PaneId(1), + root: LayoutNode::pane(PaneId(1), sid.clone()), + }], + }; + + // Live metadata that a race (or a missed call site) left un-scrubbed + // at toggle time, same as the bug: a command still sitting in + // `pane_meta` when the next dirty event fires. + let mut pane_meta = std::collections::HashMap::new(); + pane_meta.insert( + sid.clone(), + crate::PaneMeta { + cwd: Some("/a".to_string()), + last_cmd: Some("echo left".to_string()), + was_running: true, + }, + ); + + // Leg (a): the settings-effect handler scrubs it directly. + crate::clear_captured_commands(&mut pane_meta); + + // Leg (b): `pane_snap_for`'s gate is the belt-and-braces backstop — + // still holds even if a future call site skips leg (a). + let capture_commands = false; + let snap = assemble(&[(None, (80, 24), &tree, &[false])], &|s| { + crate::pane_snap_for(pane_meta.get(s), capture_commands) + }); + + let NodeSnap::Pane(p) = &snap.windows[0].tabs[0].splits else { + panic!("expected pane") + }; + assert_eq!(p.last_cmd, None); + // cwd and was_running are unrelated to the command-privacy fix and + // must survive. + assert_eq!(p.cwd.as_deref(), Some("/a")); + assert!(p.was_running); + } + + #[test] + fn strip_commands_is_a_noop_when_file_is_missing() { + let p = tmp("strip-missing").with_file_name("does-not-exist.json"); + strip_commands(&p).unwrap(); + assert!(!p.exists()); + } + + #[test] + fn strip_commands_leaves_a_corrupt_file_alone() { + let p = tmp("strip-corrupt"); + std::fs::write(&p, b"not json").unwrap(); + strip_commands(&p).unwrap(); + assert_eq!(std::fs::read(&p).unwrap(), b"not json"); + } + + // --- delete_all_state / saved_state_count: env-gated by XDG_STATE_HOME - + + /// `state_path()` reads process-wide env vars and `cargo test` runs + /// multiple tests concurrently on threads within the same process — + /// serialize every test that points `XDG_STATE_HOME` somewhere so they + /// can't stomp on each other. + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + /// Point `XDG_STATE_HOME` at a fresh scratch dir for the duration of + /// `f`, restoring the prior value (or unsetting it) afterward. Holds + /// `ENV_LOCK` for the whole call so no other env-touching test can + /// interleave. + fn with_scratch_state_home(name: &str, f: impl FnOnce(&std::path::Path) -> R) -> R { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let dir = + std::env::temp_dir().join(format!("ember-ss-xdg-{}-{}", name, std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let prior = std::env::var_os("XDG_STATE_HOME"); + // Safe: serialized by `ENV_LOCK` above, so no other thread reads or + // writes process env vars while this is set. + #[allow(unsafe_code)] + unsafe { + std::env::set_var("XDG_STATE_HOME", &dir); + } + let result = f(&dir); + #[allow(unsafe_code)] + unsafe { + match &prior { + Some(v) => std::env::set_var("XDG_STATE_HOME", v), + None => std::env::remove_var("XDG_STATE_HOME"), + } + } + result + } + + #[test] + fn saved_state_count_and_delete_cover_the_live_file_corrupt_sibling_and_archives() { + with_scratch_state_home("full", |dir| { + let path = dir.join("ember/session.json"); + write_atomic(&path, &snap("live")).unwrap(); + std::fs::write(path.with_extension("json.corrupt"), b"garbage").unwrap(); + std::fs::write( + path.with_file_name("session.json.prev-20260101-000000"), + b"{}", + ) + .unwrap(); + std::fs::write( + path.with_file_name("session.json.prev-20260102-000000"), + b"{}", + ) + .unwrap(); + // An unrelated file in the same directory must survive untouched. + std::fs::write(path.with_file_name("unrelated.txt"), b"keep me").unwrap(); + + assert_eq!(saved_state_count(), 4); + let removed = delete_all_state(); + assert_eq!(removed, 4); + assert_eq!(saved_state_count(), 0); + assert!(!path.exists()); + assert!(!path.with_extension("json.corrupt").exists()); + assert!( + !path + .with_file_name("session.json.prev-20260101-000000") + .exists() + ); + assert!(path.with_file_name("unrelated.txt").exists()); + }); + } + + #[test] + fn saved_state_count_is_zero_when_nothing_saved() { + with_scratch_state_home("empty", |_dir| { + assert_eq!(saved_state_count(), 0); + assert_eq!(delete_all_state(), 0); + }); + } + + // --- load / archive / list_archives tests ----- + + #[test] + fn load_missing_is_none_and_corrupt_is_quarantined() { + let p = tmp("load"); + assert!(matches!(load(&p), LoadOutcome::None)); + std::fs::write(&p, b"{not json").unwrap(); + assert!(matches!(load(&p), LoadOutcome::Corrupt)); + assert!(!p.exists()); + assert!(p.with_extension("json.corrupt").exists()); + } + + #[test] + fn unknown_version_is_treated_as_none() { + let p = tmp("ver"); + std::fs::write(&p, r#"{"version": 99, "saved_at": "x", "windows": []}"#).unwrap(); + assert!(matches!(load(&p), LoadOutcome::None)); + // File must remain untouched for a future version to potentially recover it + assert!(p.exists()); + } + + #[test] + fn archive_prunes_to_ten() { + let p = tmp("arch"); + let parent = p.parent().unwrap(); + + // Create 12 archives with distinct stamps + for i in 0..12 { + write_atomic(&p, &snap(&format!("s{i}"))).unwrap(); + let stamp = format!("20260801-0000{:02}", i); + archive_with_stamp(&p, &stamp).unwrap(); + } + + let list = list_archives(parent); + assert_eq!(list.len(), 10, "Expected 10 archives, got {}", list.len()); + // Verify newest first: stamps are lexicographically ordered, so newest + // has the highest last digits + assert!( + list[0].stamp > list[9].stamp, + "Archives not sorted newest first" + ); + } + + #[test] + fn archive_collision_avoidance_with_numeric_suffix() { + let p = tmp("collision"); + let parent = p.parent().unwrap(); + let stamp = "20260801-123456"; + + // Archive twice with the same stamp + write_atomic(&p, &snap("first")).unwrap(); + archive_with_stamp(&p, stamp).unwrap(); + + write_atomic(&p, &snap("second")).unwrap(); + archive_with_stamp(&p, stamp).unwrap(); + + // Both archives should exist with distinct names + let list = list_archives(parent); + assert_eq!( + list.len(), + 2, + "Expected 2 archives after collision avoidance, got {}", + list.len() + ); + + // Verify newest first (the -2 suffix sorts after the base stamp) + assert!(list[0].stamp > list[1].stamp); + + // One entry should have base stamp, the other should have -2 suffix + let stamps: Vec<&String> = list.iter().map(|e| &e.stamp).collect(); + assert!( + stamps.contains(&&stamp.to_string()) + || stamps.iter().any(|s| s.as_str() == format!("{}-2", stamp)), + "Expected base or -2 suffix stamp" + ); + } + + #[test] + fn list_archives_skips_unparseable_prev_files() { + let p = tmp("unparseable"); + let parent = p.parent().unwrap(); + + // Create one valid archive + write_atomic(&p, &snap("valid")).unwrap(); + archive_with_stamp(&p, "20260801-100000").unwrap(); + + // Create an unparseable prev-* file + std::fs::write( + parent.join("session.json.prev-20260801-110000"), + b"this is not json", + ) + .unwrap(); + + let list = list_archives(parent); + // Should only contain the valid archive, not the unparseable one + assert_eq!(list.len(), 1); + assert_eq!(list[0].stamp, "20260801-100000"); + } +} diff --git a/crates/ember-app/src/window_state.rs b/crates/ember-app/src/window_state.rs index 0d00289..1303fc5 100644 --- a/crates/ember-app/src/window_state.rs +++ b/crates/ember-app/src/window_state.rs @@ -24,9 +24,9 @@ use std::time::{Duration, Instant}; use ember_core::{ Axis, BackendControl, BackendHandle, Direction, DropZone, GridDims, LayoutCommand, - LayoutEffect, PaneId, Rect, RowKind, ScrollAmount, SessionBackend, SessionId, SettingsRowView, - SparksMode, SurfaceDest, SurfaceRef, Tab, TabId, apply, drop_zone_for, layout, remove_pane, - setting_rows, + LayoutEffect, PaneId, Rect, RestoreMode, RowKind, ScrollAmount, SessionBackend, SessionId, + SettingsRowView, SparksMode, SurfaceDest, SurfaceRef, Tab, TabId, apply, drop_zone_for, layout, + remove_pane, setting_rows, }; use ember_platform::PlatformBackend; use ember_render::{ @@ -39,6 +39,7 @@ use winit::window::{CursorIcon, WindowId}; use crate::config; use crate::control::ControlMsg; +use crate::session_state; use crate::{ ControlClose, DEFAULT_COLS, DEFAULT_ROWS, DragState, DropHover, MULTI_CLICK, PAD, PendingClose, Shared, about_info, bell_flash_intensity, bracket_paste, dims_for_rect, ember_glow, encode_key, @@ -559,6 +560,247 @@ pub(crate) struct WindowState { /// drag resolves to a `Move` might be about to be DESTROYED by that /// same move, in which case it must never re-appear first. hidden_for_carry: bool, + /// Tabs the user has explicitly renamed via the inline editor (double- + /// click + commit) — core's `Tab` has no such field, so it's tracked + /// here as a side set and fed into `TabSnap::named_by_user` at snapshot + /// time. Never pruned when a tab closes: a `TabId` is never reused + /// within a window's lifetime, so a stale entry is inert. + pub(crate) named_tabs: std::collections::HashSet, + /// The restore-on-launch modal (Task 8), if this window's the one + /// showing it — set once at startup (`restore.mode == Ask` + a loaded + /// snapshot), never re-armed later in the session. `None` on every + /// window opened after startup, and on this one too the instant the + /// prompt resolves. + pub(crate) restore_prompt: Option, +} + +/// The restore-on-launch modal's own state machine (Task 8) — mirrors +/// `PendingClose`'s "app-level enum, `WindowState` owns an `Option` of it" +/// shape. `Main` is the first screen shown; `Older` is reached via its +/// third button and, on `Esc`, returns to a freshly reloaded `Main` (see +/// `WindowState::restore_key`'s doc for why `Older` doesn't itself carry the +/// original `snap` back). +#[derive(Clone, Debug)] +pub(crate) enum RestorePrompt { + /// `focus`: which of the 3 buttons (`Restore` / `Start fresh` / + /// `Older…`, in that order) is highlighted; `u8` (not `usize`) because + /// it only ever cycles through 3 fixed values via `%`. + Main { + snap: session_state::SessionSnapshot, + focus: u8, + }, + /// `list`: up to 10 archives, newest first (`session_state:: + /// list_archives`'s own cap). `sel`: the highlighted row. + Older { + list: Vec, + sel: usize, + }, +} + +/// What the restore modal wants done, once a choice is made — returned by +/// [`WindowState::restore_key`] for `main.rs`'s keyboard handler to actually +/// perform (spawning windows and touching the filesystem both need +/// `event_loop`/paths this method doesn't have, same reasoning as +/// `PendingClose::Quit` needing its caller to call `event_loop.exit()`). +#[derive(Debug)] +pub(crate) enum RestoreAction { + /// Rebuild every window from `snapshot`. `archive_current`: whether the + /// CURRENT live session file must also be archived first — true only + /// for an `Older…` pick (the live file would otherwise still be sitting + /// there, about to be silently overwritten by the restored session's + /// own first snapshot); false for the Main "Restore" button, where the + /// live file IS the snapshot just restored (nothing to archive away). + Restore { + snapshot: session_state::SessionSnapshot, + archive_current: bool, + }, + /// "Start fresh": archive the current live session file and proceed + /// with the normal (already-created) empty default window. + StartFresh, + /// Type-through dismissal: a printable keystroke (or a scripted + /// `ControlMsg::Type`) arrived while the prompt was up. Same archive + /// semantics as `StartFresh` — the prompting window IS the fresh + /// session, so it stays open — plus the text is written to its focused + /// pane afterward so nothing the user typed is lost. + StartFreshAndType(String), +} + +/// What one key press does to the restore modal, independent of which +/// screen (`Main`/`Older`) is showing — mirrors `settings_action_for_key`: +/// a pure function, unit-tested directly, since the GPU-coupled +/// `WindowState` this classification ultimately feeds can't be constructed +/// in tests. `restore_key` interprets `Nav`'s direction differently per +/// screen (Left/Right/Tab cycle `Main`'s 3 buttons; Up/Down move `Older`'s +/// list cursor) and does the actual state mutation/I/O itself — this type +/// only decides WHAT KIND of thing a key press is, not what effect it has. +#[derive(Clone, Debug, PartialEq)] +enum RestoreKeyAction { + /// A directional key with a screen-specific meaning (see this enum's + /// doc): Left, Right/Tab, Up, or Down. + Nav(RestoreNavDir), + /// Enter: confirm the highlighted button (`Main`) or list row (`Older`). + Activate, + /// Esc: back out — "Start fresh" from `Main`, return to `Main` from + /// `Older`. + Back, + /// A printable keystroke (`Key::Character`, or `Space` normalized to a + /// literal space) held with no Ctrl/Super modifier — the product + /// ruling: typing while the prompt is up means "get me to a shell + /// now," so this always resolves to `RestoreAction::StartFreshAndType`, + /// in both screens. + TypeThrough(String), + /// Anything else: function keys, bare modifiers, a Ctrl-modified + /// character (Ctrl+C must not literally type "c" into the fresh + /// shell), or — defense in depth for callers that could somehow reach + /// this with Super held — a Super-modified character. No-op, matching + /// today's swallow. Real Super *chords* (Cmd+Q, Cmd+W, …) never reach + /// this function at all on the keyboard path: `main.rs` skips the + /// restore-modal branch entirely while Super is held, exactly like the + /// palette/search/rename capture guards, so they fall through to the + /// normal shortcut handlers below instead of being classified here. + Swallow, +} + +/// Direction for [`RestoreKeyAction::Nav`] — see that variant's doc for how +/// `Main`/`Older` each interpret it. +#[derive(Clone, Copy, Debug, PartialEq)] +enum RestoreNavDir { + Left, + Right, + Up, + Down, +} + +/// Classify one key press against the restore modal's fixed control +/// surface, given the modifiers held alongside it. See +/// [`RestoreKeyAction`]'s doc for why this is a free pure function rather +/// than a `WindowState` method, and for why `mods` only ever gates +/// `TypeThrough` rather than the named control keys (Escape/Enter/arrows/ +/// Tab keep their meaning modifier or not, unchanged from before this +/// keystroke ever carried a modifier check). +fn restore_key_action(key: &Key, mods: ModifiersState) -> RestoreKeyAction { + match key { + Key::Named(NamedKey::Escape) => RestoreKeyAction::Back, + Key::Named(NamedKey::Enter) => RestoreKeyAction::Activate, + Key::Named(NamedKey::ArrowLeft) => RestoreKeyAction::Nav(RestoreNavDir::Left), + Key::Named(NamedKey::ArrowRight) | Key::Named(NamedKey::Tab) => { + RestoreKeyAction::Nav(RestoreNavDir::Right) + } + Key::Named(NamedKey::ArrowUp) => RestoreKeyAction::Nav(RestoreNavDir::Up), + Key::Named(NamedKey::ArrowDown) => RestoreKeyAction::Nav(RestoreNavDir::Down), + Key::Named(NamedKey::Space) if !mods.control_key() && !mods.super_key() => { + RestoreKeyAction::TypeThrough(" ".to_string()) + } + Key::Character(s) if !mods.control_key() && !mods.super_key() => { + RestoreKeyAction::TypeThrough(s.to_string()) + } + _ => RestoreKeyAction::Swallow, + } +} + +/// The restore modal's header line: `"Restore N windows, M tabs (from +/// )?"`, singular-aware (`1 window`/`1 tab`) — shared by `Main` (built +/// straight from its own `snap`) and `Older` (re-derived from the reloaded +/// live file, see `RestorePrompt`'s doc). +fn restore_header(snap: &session_state::SessionSnapshot, now: std::time::SystemTime) -> String { + let windows = snap.windows.len(); + let tabs: usize = snap.windows.iter().map(|w| w.tabs.len()).sum(); + format!( + "Restore {windows} window{}, {tabs} tab{} (from {})?", + if windows == 1 { "" } else { "s" }, + if tabs == 1 { "" } else { "s" }, + session_state::humanize_age(&snap.saved_at, now), + ) +} + +/// What a key press does while the Settings overlay is open, given the +/// currently selected row's `kind` (`None` when nothing is selected, e.g. +/// an empty row list). Pure and independent of `WindowState`/`Shared` — +/// unit-tested directly (see `settings_action_for_key`'s tests) rather than +/// through a live overlay, since `Renderer` needs a GPU context this crate's +/// test harness doesn't provide. +/// +/// The critical bit this type exists to make impossible to get wrong again: +/// `Activate` (which fires a `RowKind::Action` row, e.g. "Delete saved +/// sessions") is only ever produced for Enter/Space, never for +/// Left/Right — so routine navigation on an action row can never trigger +/// it, even though Left/Right/Space/Enter all drive ordinary rows via the +/// same `Adjust` variant. +#[derive(Clone, Copy, Debug, PartialEq)] +enum SettingsAction { + Close, + MoveUp, + MoveDown, + /// Adjust the selected row's value by this direction (+1.0 / -1.0) — + /// meaningless for `RowKind::Action` rows, which never produce this. + Adjust(f32), + /// Fire the selected `RowKind::Action` row. Only ever produced when the + /// selected row's kind is `RowKind::Action` and the key was Enter or + /// Space. + Activate, + None, +} + +/// Map a key press onto a [`SettingsAction`], given the currently selected +/// row's `kind`. See [`SettingsAction`]'s docs for the invariant this +/// exists to enforce. +fn settings_action_for_key(key: &Key, selected_kind: Option) -> SettingsAction { + let is_action_row = selected_kind == Some(RowKind::Action); + match key { + Key::Named(NamedKey::Escape) => SettingsAction::Close, + Key::Named(NamedKey::ArrowUp) => SettingsAction::MoveUp, + Key::Named(NamedKey::ArrowDown) => SettingsAction::MoveDown, + Key::Named(NamedKey::ArrowRight) => { + if is_action_row { + SettingsAction::None + } else { + SettingsAction::Adjust(1.0) + } + } + Key::Named(NamedKey::ArrowLeft) => { + if is_action_row { + SettingsAction::None + } else { + SettingsAction::Adjust(-1.0) + } + } + Key::Named(NamedKey::Space) | Key::Named(NamedKey::Enter) => { + if is_action_row { + SettingsAction::Activate + } else { + SettingsAction::Adjust(1.0) + } + } + _ => SettingsAction::None, + } +} + +/// Find the `SettingRow` in `ember_core`'s static table whose label matches +/// `label` exactly — what `adjust_setting` looks up to call the row's own +/// `adjust` fn. Pure (no `Shared`/`WindowState`), so the label-matching +/// itself — the fix for the resolved-view/static-table index mismatch +/// `resolve_rows` introduced once it could hide/synthesize rows — is +/// unit-testable on its own. Returns `None` for a label that isn't in the +/// static table at all, e.g. the synthesized "Delete saved sessions (N)…" +/// row, which is intentional: that row has no `Config`-mutating `adjust`, +/// so a miss here just means "nothing to adjust," not a bug. +fn find_setting_row_by_label(label: &str) -> Option<&'static ember_core::SettingRow> { + setting_rows().iter().find(|r| r.label == label) +} + +/// Send raw bytes to one specific session's PTY, wherever it lives. A +/// generalization of `WindowState::send_to_focused` (which delegates here) +/// for callers — like the restored-session pre-type latch — that need to +/// address a pane that isn't necessarily focused, or isn't even in the +/// caller's own window. Pure lookup through `shared.sessions`: no +/// `WindowState`/pane-tree traversal needed, since the PTY handle is keyed +/// by `SessionId` alone. +pub(crate) fn send_to_pane(shared: &Shared, session_id: &SessionId, bytes: Vec) { + if let Some(h) = shared.sessions.get(session_id) { + let _ = h + .control + .send(BackendControl::Input(bytes.into_boxed_slice())); + } } impl WindowState { @@ -624,6 +866,8 @@ impl WindowState { carried_exclusion: None, exclusion_applied: false, hidden_for_carry: false, + named_tabs: std::collections::HashSet::new(), + restore_prompt: None, } } @@ -806,10 +1050,8 @@ impl WindowState { /// Send raw bytes to the focused session's PTY (used by control + key paths). pub(crate) fn send_to_focused(&self, shared: &Shared, bytes: Vec) { - if let Some(h) = self.focused_session(shared) { - let _ = h - .control - .send(BackendControl::Input(bytes.into_boxed_slice())); + if let Some(id) = self.focused_session_id() { + send_to_pane(shared, &id, bytes); } } @@ -859,6 +1101,48 @@ impl WindowState { return None; } } + // Mirror the keyboard: the restore-on-launch modal captures input + // (Left/Right/Tab/Up/Down navigate, Enter activates, Esc backs out, + // a printable key or a whole `Type` types through — see + // `restore_key_action`/`RestoreAction::StartFreshAndType`). + // + // No Super-bypass gate here (contrast the keyboard call site in + // `main.rs`): `ControlMsg::Key`'s `named_key` lookup produces a bare + // `Key` with no modifier at all, so a ctl caller has no way to + // synthesize a Super-held (or Ctrl-held) character in the first + // place — `restore_key`'s own `mods: ModifiersState::empty()` below + // is therefore always the true state, not a lossy approximation. + // `ControlMsg::Chord` (the ctl verb that DOES carry modifiers, e.g. + // "cmd+q") isn't matched below at all and falls to `_ => {}` — + // already swallowed before this change, unchanged by it. + if self.restore_prompt.is_some() { + match &msg { + ControlMsg::Key(name) => { + if let Some(k) = named_key(name) { + if let Some(action) = self.restore_key(&k, ModifiersState::empty()) { + return Some(ControlClose::Restore(action)); + } + self.renderer.window().request_redraw(); + } + } + // Scripted launches send the whole typed string in one shot + // rather than one `ControlMsg::Key` per character — same + // dismissal as a single printable keystroke, just with the + // full text riding along instead of one character. An empty + // string carries nothing to forward, so it's a no-op: the + // modal stays up rather than archiving and dismissing for + // literally nothing typed. + ControlMsg::Type(text) if !text.is_empty() => { + self.restore_prompt = None; + self.update_restore_view(); + return Some(ControlClose::Restore(RestoreAction::StartFreshAndType( + text.clone(), + ))); + } + _ => {} + } + return None; + } // Mirror the keyboard: the close-confirm modal captures input (arrows/Tab // move focus, Enter activates, Esc cancels). if self.pending_close.is_some() { @@ -1013,6 +1297,7 @@ impl WindowState { let vp = self.viewport(); apply(&mut self.tree, LayoutCommand::MoveTab { from, to }, vp); self.sync_layout(shared); + shared.snapshot_dirty = true; } ControlMsg::RenameTab(i, name) => { if let Some(t) = self.tree.tabs.get(i) { @@ -1027,6 +1312,7 @@ impl WindowState { vp, ); self.sync_layout(shared); + shared.snapshot_dirty = true; } } ControlMsg::EditTab(i) => self.start_rename(shared, i), @@ -1444,8 +1730,6 @@ impl WindowState { axis: Axis, ratio: f64, ) { - let new_pane = PaneId(shared.next_pane); - let new_session = SessionId::new(format!("s{}", shared.next_session)); // Cwd-inheriting split (design §8.1): the new pane starts where the // split's parent pane last reported itself (OSC 1337 `CurrentDir`). let inherited_cwd = self @@ -1454,6 +1738,27 @@ impl WindowState { .session_of(target) .and_then(|sid| shared.cwd_by_session.get(sid)) .cloned(); + self.split_pane_with_cwd(shared, target, axis, ratio, inherited_cwd); + } + + /// Same as [`Self::split_pane`], but with an EXPLICIT cwd for the new + /// pane instead of inheriting `target`'s live-reported OSC 1337 dir — + /// used by the session-restore rebuild path (`main.rs`'s + /// `spawn_restored`), where `target`'s shell was JUST spawned and has + /// had no chance to report a cwd yet (`shared.cwd_by_session` has + /// nothing to inherit there); the caller resolves the real cwd (or a + /// `$HOME` fallback) from the saved snapshot instead and passes it here + /// directly. + pub(crate) fn split_pane_with_cwd( + &mut self, + shared: &mut Shared, + target: PaneId, + axis: Axis, + ratio: f64, + cwd: Option, + ) { + let new_pane = PaneId(shared.next_pane); + let new_session = SessionId::new(format!("s{}", shared.next_session)); let vp = self.viewport(); let min_px = self.min_px(axis); // Spawn only if the split is actually accepted (min-size may refuse it), @@ -1480,7 +1785,7 @@ impl WindowState { shared, new_session, GridDims::new(DEFAULT_COLS, DEFAULT_ROWS), - inherited_cwd, + cwd, ) { // Spawn failed after the tree accepted the split: roll the pane back // out so we don't render a dead pane. @@ -1496,6 +1801,7 @@ impl WindowState { } self.apply_effects(shared, effects); self.sync_layout(shared); + shared.snapshot_dirty = true; } /// Whether Ctrl+Opt is currently held (the visual-split modifier). @@ -1598,27 +1904,41 @@ impl WindowState { let vp = self.viewport(); let effects = apply(&mut self.tree, LayoutCommand::ClosePane { target }, vp); self.apply_effects(shared, effects); + shared.snapshot_dirty = true; if !self.tree.tabs.is_empty() { self.sync_layout(shared); } } pub(crate) fn new_tab(&mut self, shared: &mut Shared) { + // Design §8.1 scopes cwd inheritance to splits, not new tabs — a new + // tab starts at the shell's own default, same as today. + self.new_tab_with_cwd(shared, None); + } + + /// Same as [`Self::new_tab`], but the fresh tab's seed pane spawns in + /// `cwd` instead of the shell's own default — used by the + /// session-restore rebuild path (`main.rs`'s `spawn_restored`) to seed + /// each restored tab in its saved directory. Returns whether the tab + /// was actually created (`false` only if the shell failed to spawn, in + /// which case nothing was added — mirrors `new_tab`'s existing + /// early-return-on-spawn-failure, just surfaced to the caller instead of + /// silently swallowed, since the rebuild path needs to know whether to + /// keep going with this tab's titling/splits or skip it). + pub(crate) fn new_tab_with_cwd(&mut self, shared: &mut Shared, cwd: Option) -> bool { let id = TabId(shared.next_tab); shared.next_tab += 1; let pane = PaneId(shared.next_pane); shared.next_pane += 1; let session = SessionId::new(format!("s{}", shared.next_session)); shared.next_session += 1; - // Design §8.1 scopes cwd inheritance to splits, not new tabs — a new - // tab starts at the shell's own default, same as today. if !self.spawn_session( shared, session.clone(), GridDims::new(DEFAULT_COLS, DEFAULT_ROWS), - None, + cwd, ) { - return; + return false; } let vp = self.viewport(); let effects = apply( @@ -1628,6 +1948,8 @@ impl WindowState { ); self.apply_effects(shared, effects); self.sync_layout(shared); + shared.snapshot_dirty = true; + true } /// Keyboard resize of the focused pane: `dir` (±1) grows/shrinks it by a few @@ -1851,6 +2173,11 @@ impl WindowState { } else if let Some(d) = self.tab_drag.take() { self.renderer.set_tab_drag(None); ended = if d.active { + // The strip live-reorders `self.tree` as the drag crosses + // slot boundaries (`drag_tab_to`, which only has `&Shared`) + // — this release is the first point with `&mut Shared` to + // mark the result dirty. + shared.snapshot_dirty = true; DragEnded::Reorder } else { DragEnded::None @@ -3208,6 +3535,12 @@ impl WindowState { LayoutCommand::MoveTab { from: src_tab, to }, vp, ); + // Same-window tear-off-and-redrop reorder: unlike the + // in-strip live reorder (`drag_tab_to` + `left_release`) + // and the cross-window path (`apply_move`), this arm + // mutates `self.tree` directly and never goes through + // either funnel. + shared.snapshot_dirty = true; } self.tree.active = to; self.sync_layout(shared); @@ -3353,8 +3686,10 @@ impl WindowState { self.sync_layout(shared); } - /// Commit the in-progress rename (Enter / click away) → sets the tab title. - pub(crate) fn commit_rename(&mut self, shared: &Shared) { + /// Commit the in-progress rename (Enter / click away) → sets the tab title + /// and, since this is the interactive "user typed a name" path, marks the + /// tab as user-named for the session snapshot (`named_tabs`). + pub(crate) fn commit_rename(&mut self, shared: &mut Shared) { let Some(i) = self.editing_tab.take() else { return; }; @@ -3367,6 +3702,8 @@ impl WindowState { LayoutCommand::RenameTab { tab: id, title }, vp, ); + self.named_tabs.insert(id); + shared.snapshot_dirty = true; } self.edit_buffer.clear(); self.sync_layout(shared); @@ -3381,7 +3718,7 @@ impl WindowState { } /// Route a key into the inline tab-rename editor. - pub(crate) fn rename_key(&mut self, shared: &Shared, key: &Key) { + pub(crate) fn rename_key(&mut self, shared: &mut Shared, key: &Key) { match key { Key::Named(NamedKey::Enter) => self.commit_rename(shared), Key::Named(NamedKey::Escape) => self.cancel_rename(shared), @@ -3937,6 +4274,184 @@ impl WindowState { } } + /// Show the restore-on-launch modal (startup only — see `main.rs`'s + /// `resumed`). Exclusive with the close-confirm modal in practice, but + /// there's nothing to hide here: at the point this is called, nothing + /// else has had a chance to show yet. + pub(crate) fn show_restore_prompt(&mut self, snap: session_state::SessionSnapshot) { + self.restore_prompt = Some(RestorePrompt::Main { snap, focus: 0 }); + self.update_restore_view(); + } + + /// (Re)build the renderer's `RestoreView` from `self.restore_prompt`. + /// No-op-safe to call with `restore_prompt == None` (clears the view). + pub(crate) fn update_restore_view(&mut self) { + let now = std::time::SystemTime::now(); + let view = match &self.restore_prompt { + None => None, + Some(RestorePrompt::Main { snap, focus }) => Some(ember_render::RestoreView::Main { + header: restore_header(snap, now), + focused: *focus as usize, + }), + Some(RestorePrompt::Older { list, sel }) => { + // The header stays the same restore question throughout — + // re-derive it from the CURRENT live file rather than + // threading the original `snap` through `Older` (see + // `RestorePrompt`'s doc for why `Older` doesn't carry one). + let header = session_state::state_path() + .map(|p| session_state::load(&p)) + .and_then(|o| match o { + session_state::LoadOutcome::Loaded(s) => Some(restore_header(&s, now)), + _ => None, + }) + .unwrap_or_else(|| "Restore session?".to_string()); + Some(ember_render::RestoreView::Older { + header, + rows: list + .iter() + .map(|e| { + format!( + "{} · {} window{}, {} tab{}", + session_state::humanize_stamp(&e.stamp, now), + e.windows, + if e.windows == 1 { "" } else { "s" }, + e.tabs, + if e.tabs == 1 { "" } else { "s" }, + ) + }) + .collect(), + selected: *sel, + }) + } + }; + self.renderer.set_restore(view); + } + + /// Handle one key while the restore modal is open. Pure navigation is + /// resolved entirely here (including reading `list_archives`/re-loading + /// the live file off disk, both cheap local I/O); an actual choice + /// (Restore / Start fresh) is returned as a [`RestoreAction`] for the + /// caller (`main.rs`'s keyboard handler) to carry out — spawning + /// windows and archiving files both need state (`Shared`, `event_loop`) + /// this method doesn't have. + /// + /// `Older`'s `Esc` returns to `Main` by RE-READING the live session file + /// rather than restoring a stashed copy: `RestorePrompt::Older` (per its + /// own doc) doesn't carry the original `snap`, and the live file is + /// guaranteed untouched while any window's `restore_prompt.is_some()` + /// (`main.rs`'s `session_dirty` guard) — so the reload always reproduces + /// exactly what `Main` showed before. + /// + /// `mods`: the modifiers held alongside `key`, fed straight into + /// [`restore_key_action`]. The keyboard call site (`main.rs`) never + /// reaches here at all while Super is held — that branch is skipped + /// entirely so Cmd+Q/Cmd+W/etc. fall through to the real shortcut + /// handlers, matching the palette/search/rename capture guards — so in + /// practice this only ever sees Super held via a hypothetical future + /// ctl caller; `ControlMsg::Key` today has no way to attach modifiers + /// at all (see `handle_control`'s restore-prompt branch), so it always + /// passes `ModifiersState::empty()`. + pub(crate) fn restore_key(&mut self, key: &Key, mods: ModifiersState) -> Option { + let key_action = restore_key_action(key, mods); + let prompt = self.restore_prompt.as_mut()?; + if let RestoreKeyAction::TypeThrough(text) = key_action { + self.restore_prompt = None; + self.update_restore_view(); + return Some(RestoreAction::StartFreshAndType(text)); + } + match prompt { + RestorePrompt::Main { snap, focus } => match key_action { + RestoreKeyAction::Back => { + self.restore_prompt = None; + self.update_restore_view(); + Some(RestoreAction::StartFresh) + } + RestoreKeyAction::Nav(RestoreNavDir::Left) => { + *focus = (*focus + 2) % 3; // -1 mod 3 + self.update_restore_view(); + None + } + RestoreKeyAction::Nav(RestoreNavDir::Right) => { + *focus = (*focus + 1) % 3; + self.update_restore_view(); + None + } + RestoreKeyAction::Activate => match *focus { + 0 => { + let snapshot = snap.clone(); + self.restore_prompt = None; + self.update_restore_view(); + Some(RestoreAction::Restore { + snapshot, + archive_current: false, + }) + } + 1 => { + self.restore_prompt = None; + self.update_restore_view(); + Some(RestoreAction::StartFresh) + } + _ => { + let list = session_state::state_path() + .and_then(|p| p.parent().map(session_state::list_archives)) + .unwrap_or_default(); + self.restore_prompt = Some(RestorePrompt::Older { list, sel: 0 }); + self.update_restore_view(); + None + } + }, + // Up/Down have no meaning on `Main`; `TypeThrough` is fully + // handled above before this match ever runs. + _ => None, + }, + RestorePrompt::Older { list, sel } => match key_action { + RestoreKeyAction::Back => { + self.reload_restore_main(2); + None + } + RestoreKeyAction::Nav(RestoreNavDir::Up) => { + *sel = sel.saturating_sub(1); + self.update_restore_view(); + None + } + RestoreKeyAction::Nav(RestoreNavDir::Down) => { + if *sel + 1 < list.len() { + *sel += 1; + } + self.update_restore_view(); + None + } + RestoreKeyAction::Activate => { + let entry = list.get(*sel).cloned()?; + let snapshot = session_state::load_archive(&entry.path)?; + self.restore_prompt = None; + self.update_restore_view(); + Some(RestoreAction::Restore { + snapshot, + archive_current: true, + }) + } + // Left/Right have no meaning on `Older`; `TypeThrough` is + // fully handled above before this match ever runs. + _ => None, + }, + } + } + + /// `Older…`'s `Esc`: reload the live session file and return to `Main`, + /// focused on the button (`focus`) that led here — see `restore_key`'s + /// doc for why this reloads rather than stashing/restoring `snap`. + fn reload_restore_main(&mut self, focus: u8) { + let snap = session_state::state_path() + .map(|p| session_state::load(&p)) + .and_then(|o| match o { + session_state::LoadOutcome::Loaded(s) => Some(s), + _ => None, + }); + self.restore_prompt = snap.map(|snap| RestorePrompt::Main { snap, focus }); + self.update_restore_view(); + } + /// Dismiss whichever modal overlay is open; returns whether one was showing. pub(crate) fn dismiss_overlay(&mut self) -> bool { let shown = self.help || self.about || self.settings_open; @@ -4008,38 +4523,110 @@ impl WindowState { /// Handle a key while the Settings overlay is open: navigate + change values. pub(crate) fn settings_key(&mut self, shared: &mut Shared, key: &Key) { let rows = shared.settings_rows(); - match key { - Key::Named(NamedKey::Escape) => { + let selected_kind = rows.get(self.settings_sel).map(|r| r.kind); + match settings_action_for_key(key, selected_kind) { + SettingsAction::Close => { self.hide_settings(); return; } - Key::Named(NamedKey::ArrowUp) => { + SettingsAction::MoveUp => { self.settings_sel = step_selectable_row(&rows, self.settings_sel, -1); } - Key::Named(NamedKey::ArrowDown) => { + SettingsAction::MoveDown => { self.settings_sel = step_selectable_row(&rows, self.settings_sel, 1); } - Key::Named(NamedKey::ArrowRight) | Key::Named(NamedKey::Space) => { - self.adjust_setting(shared, 1.0) + SettingsAction::Adjust(dir) => { + self.adjust_setting_and_apply_restore_effects(shared, dir) } - Key::Named(NamedKey::ArrowLeft) => self.adjust_setting(shared, -1.0), - _ => {} - } + SettingsAction::Activate => self.activate_action_row(), + SettingsAction::None => {} + } + // Rows can appear/disappear as a result of the action above (the + // Capture-commands and Delete-saved-sessions rows both depend on + // live state, not just table position) — re-resolve and make sure + // the selection didn't land on a header or fall off the end before + // pushing the refreshed rows to the renderer. + let rows = shared.settings_rows(); + self.ensure_settings_sel_selectable(&rows); self.refresh_settings(shared); } + /// Fire the currently selected `RowKind::Action` row. Only "Delete + /// saved sessions" exists today, so there's nothing to dispatch on by + /// label yet. Safe to call unconditionally: the only caller is + /// `settings_key`, which only ever produces `SettingsAction::Activate` + /// (routed here) when `settings_action_for_key` has already confirmed + /// the selected row is `RowKind::Action` — and that function gates + /// `Activate` to Enter/Space only, never Left/Right, so routine + /// navigation can never delete anything. + fn activate_action_row(&mut self) { + let removed = session_state::delete_all_state(); + eprintln!("[ember] deleted {removed} saved session file(s)"); + } + + /// Adjust the selected setting by `dir`, then apply the session-restore + /// side effects `config.restore` can trigger — spawning/dropping the + /// snapshot writer, the immediate command-strip — by diffing `restore` + /// before and after `adjust_setting`. + fn adjust_setting_and_apply_restore_effects(&mut self, shared: &mut Shared, dir: f32) { + let before = shared.config.restore.clone(); + self.adjust_setting(shared, dir); + let after = shared.config.restore.clone(); + + if before.mode != after.mode { + match after.mode { + RestoreMode::Off => { + // Drop flushes any pending snapshot; existing files on + // disk are left alone (ruling) — only the explicit + // Delete action removes them. + shared.snapshots = None; + } + RestoreMode::Ask | RestoreMode::Always => { + if shared.snapshots.is_none() { + shared.snapshots = + session_state::state_path().map(session_state::SnapshotWriter::spawn); + } + } + } + } + + if before.capture_commands && !after.capture_commands { + // Belt and braces (see `crate::pane_snap_for`'s doc): clear the + // live metadata that the NEXT `session_dirty` would otherwise + // reassemble commands from, strip whatever's already on disk, + // then mark dirty so a command-free snapshot supersedes + // anything a pre-toggle dirty event already queued in the + // debounced writer (which could otherwise flush after this + // strip and put the commands right back). + crate::clear_captured_commands(&mut shared.pane_meta); + if let Some(path) = session_state::state_path() { + if let Err(e) = session_state::strip_commands(&path) { + eprintln!("[ember] session command strip failed: {e}"); + } + } + shared.snapshot_dirty = true; + } + } + /// Change the selected setting by `dir` (+1 / -1) via its own row's - /// `adjust` fn — the row table *is* the dispatch, there is no positional - /// match here to drift out of sync with it. Persists the config, then - /// re-applies every live side effect unconditionally: backdrop + /// `adjust` fn. Looks the row up in `ember_core`'s static table by + /// label rather than by `self.settings_sel`'s position: the resolved + /// view `shared.settings_rows()` returns can hide or synthesize rows + /// (session-restore's Capture-commands/Delete-saved-sessions rows), so + /// its indices no longer line up 1:1 with the static table's — the + /// label is the one thing both sides agree on. Persists the config, + /// then re-applies every live side effect unconditionally: backdrop /// appearance, font size, font family, and the developer-mode control /// socket. Each is already a cheap no-op when its target value hasn't /// changed (matching `zoom_to`'s existing no-op-if-unchanged pattern), /// so this never needs to know which row actually fired. pub(crate) fn adjust_setting(&mut self, shared: &mut Shared, dir: f32) { - if let Some(row) = setting_rows().get(self.settings_sel) { - if let Some(adjust) = row.adjust { - adjust(&mut shared.config, dir); + let rows = shared.settings_rows(); + if let Some(selected) = rows.get(self.settings_sel) { + if let Some(row) = find_setting_row_by_label(&selected.label) { + if let Some(adjust) = row.adjust { + adjust(&mut shared.config, dir); + } } } if let Err(e) = config::save(&shared.config) { @@ -4246,6 +4833,7 @@ impl WindowState { vp, ); self.apply_effects(shared, effects); + shared.snapshot_dirty = true; if self.tree.tabs.is_empty() { return true; } @@ -4271,6 +4859,7 @@ impl WindowState { let vp = self.viewport(); let effects = apply(&mut self.tree, LayoutCommand::CloseTab { tab }, vp); self.apply_effects(shared, effects); + shared.snapshot_dirty = true; if self.tree.tabs.is_empty() { return true; } @@ -4469,4 +5058,325 @@ mod tests { SurfaceRef::Tab { window: 4, tab: 0 } ); } + + // --- settings_action_for_key: the arrows-must-never-delete fix --------- + // + // Building a live `WindowState`/`Shared` here isn't practical (the + // `Renderer` needs a real GPU context this crate's test harness doesn't + // provide), so the key -> action decision was extracted into a pure + // function and is tested directly here, independent of the overlay. + + #[test] + fn action_row_arrow_keys_do_nothing() { + use super::{SettingsAction, settings_action_for_key}; + use ember_core::RowKind; + use winit::keyboard::{Key, NamedKey}; + + for key in [ + Key::Named(NamedKey::ArrowLeft), + Key::Named(NamedKey::ArrowRight), + ] { + assert_eq!( + settings_action_for_key(&key, Some(RowKind::Action)), + SettingsAction::None, + "{key:?} on an Action row must not delete anything" + ); + } + } + + #[test] + fn action_row_enter_or_space_activates() { + use super::{SettingsAction, settings_action_for_key}; + use ember_core::RowKind; + use winit::keyboard::{Key, NamedKey}; + + for key in [Key::Named(NamedKey::Enter), Key::Named(NamedKey::Space)] { + assert_eq!( + settings_action_for_key(&key, Some(RowKind::Action)), + SettingsAction::Activate, + "{key:?} on an Action row must activate it" + ); + } + } + + #[test] + fn non_action_row_arrow_keys_adjust_in_the_matching_direction() { + use super::{SettingsAction, settings_action_for_key}; + use ember_core::RowKind; + use winit::keyboard::{Key, NamedKey}; + + for kind in [RowKind::Toggle, RowKind::Cycle, RowKind::Number] { + assert_eq!( + settings_action_for_key(&Key::Named(NamedKey::ArrowRight), Some(kind)), + SettingsAction::Adjust(1.0) + ); + assert_eq!( + settings_action_for_key(&Key::Named(NamedKey::ArrowLeft), Some(kind)), + SettingsAction::Adjust(-1.0) + ); + } + } + + #[test] + fn non_action_row_enter_and_space_adjust_forward() { + use super::{SettingsAction, settings_action_for_key}; + use ember_core::RowKind; + use winit::keyboard::{Key, NamedKey}; + + for key in [Key::Named(NamedKey::Enter), Key::Named(NamedKey::Space)] { + assert_eq!( + settings_action_for_key(&key, Some(RowKind::Toggle)), + SettingsAction::Adjust(1.0) + ); + } + } + + #[test] + fn navigation_and_escape_are_unaffected_by_row_kind() { + use super::{SettingsAction, settings_action_for_key}; + use ember_core::RowKind; + use winit::keyboard::{Key, NamedKey}; + + for kind in [None, Some(RowKind::Action), Some(RowKind::Toggle)] { + assert_eq!( + settings_action_for_key(&Key::Named(NamedKey::Escape), kind), + SettingsAction::Close + ); + assert_eq!( + settings_action_for_key(&Key::Named(NamedKey::ArrowUp), kind), + SettingsAction::MoveUp + ); + assert_eq!( + settings_action_for_key(&Key::Named(NamedKey::ArrowDown), kind), + SettingsAction::MoveDown + ); + } + } + + #[test] + fn an_unhandled_key_is_a_no_op_regardless_of_row_kind() { + use super::{SettingsAction, settings_action_for_key}; + use ember_core::RowKind; + use winit::keyboard::{Key, NamedKey}; + + for kind in [None, Some(RowKind::Action), Some(RowKind::Toggle)] { + assert_eq!( + settings_action_for_key(&Key::Named(NamedKey::Tab), kind), + SettingsAction::None + ); + } + } + + // --- restore_key_action: type-through dismissal (PR 13) ---------------- + // + // Same reasoning as `settings_action_for_key`'s tests above: the restore + // modal's key -> action decision is a pure function precisely so it can + // be tested here without a live `WindowState`/GPU-backed `Renderer`. + // `restore_key_action` itself doesn't know about `Main`/`Older` — both + // screens funnel through it, so these tests cover both by construction + // rather than by asserting on a live prompt. + + #[test] + fn printable_characters_type_through() { + use super::{RestoreKeyAction, restore_key_action}; + use winit::keyboard::{Key, ModifiersState, SmolStr}; + + for ch in ["a", "Z", "5", "?", "\u{222b}"] { + assert_eq!( + restore_key_action(&Key::Character(SmolStr::new(ch)), ModifiersState::empty()), + RestoreKeyAction::TypeThrough(ch.to_string()), + "printable {ch:?} must type through" + ); + } + } + + #[test] + fn space_types_through_as_a_literal_space() { + use super::{RestoreKeyAction, restore_key_action}; + use winit::keyboard::{Key, ModifiersState, NamedKey}; + + assert_eq!( + restore_key_action(&Key::Named(NamedKey::Space), ModifiersState::empty()), + RestoreKeyAction::TypeThrough(" ".to_string()) + ); + } + + #[test] + fn enter_activates() { + use super::{RestoreKeyAction, restore_key_action}; + use winit::keyboard::{Key, ModifiersState, NamedKey}; + + assert_eq!( + restore_key_action(&Key::Named(NamedKey::Enter), ModifiersState::empty()), + RestoreKeyAction::Activate + ); + } + + #[test] + fn escape_backs_out() { + use super::{RestoreKeyAction, restore_key_action}; + use winit::keyboard::{Key, ModifiersState, NamedKey}; + + assert_eq!( + restore_key_action(&Key::Named(NamedKey::Escape), ModifiersState::empty()), + RestoreKeyAction::Back + ); + } + + #[test] + fn arrows_and_tab_classify_as_directional_nav() { + use super::{RestoreKeyAction, RestoreNavDir, restore_key_action}; + use winit::keyboard::{Key, ModifiersState, NamedKey}; + + // Left/Right/Tab are `Main`'s button-cycling keys; Up/Down are + // `Older`'s list-cursor keys — `restore_key_action` classifies all + // of them uniformly, and `restore_key` picks the ones that mean + // something on the screen that's actually open. + assert_eq!( + restore_key_action(&Key::Named(NamedKey::ArrowLeft), ModifiersState::empty()), + RestoreKeyAction::Nav(RestoreNavDir::Left) + ); + assert_eq!( + restore_key_action(&Key::Named(NamedKey::ArrowRight), ModifiersState::empty()), + RestoreKeyAction::Nav(RestoreNavDir::Right) + ); + assert_eq!( + restore_key_action(&Key::Named(NamedKey::Tab), ModifiersState::empty()), + RestoreKeyAction::Nav(RestoreNavDir::Right) + ); + assert_eq!( + restore_key_action(&Key::Named(NamedKey::ArrowUp), ModifiersState::empty()), + RestoreKeyAction::Nav(RestoreNavDir::Up) + ); + assert_eq!( + restore_key_action(&Key::Named(NamedKey::ArrowDown), ModifiersState::empty()), + RestoreKeyAction::Nav(RestoreNavDir::Down) + ); + } + + #[test] + fn unhandled_named_keys_are_swallowed() { + use super::{RestoreKeyAction, restore_key_action}; + use winit::keyboard::{Key, ModifiersState, NamedKey}; + + // Stand-ins for "IME/other named keys keep today's swallow" — + // anything with no defined modal meaning and no printable text. + for key in [ + Key::Named(NamedKey::F1), + Key::Named(NamedKey::Backspace), + Key::Named(NamedKey::Shift), + ] { + assert_eq!( + restore_key_action(&key, ModifiersState::empty()), + RestoreKeyAction::Swallow + ); + } + } + + // --- restore_key_action: modifier gating (review fix) ------------------ + // + // Super-modified characters never reach `restore_key_action` on the real + // keyboard path at all — `main.rs` skips the whole restore-modal branch + // while Super is held, exactly like the palette/search/rename capture + // guards, so Cmd+Q/Cmd+W/etc. fall through to the normal shortcut + // handlers untouched. These tests cover the classifier's OWN defense in + // depth for that case (a hypothetical ctl caller that could somehow + // attach Super to a `Key`) plus the real, reachable case: Ctrl-modified + // characters, which DO reach here (nothing gates entry on Ctrl), and + // must not type a literal letter for what's meant as a control chord + // (Ctrl+C, Ctrl+D, …). + + #[test] + fn plain_characters_are_unaffected_by_the_modifier_gate() { + use super::{RestoreKeyAction, restore_key_action}; + use winit::keyboard::{Key, ModifiersState, SmolStr}; + + assert_eq!( + restore_key_action(&Key::Character(SmolStr::new("a")), ModifiersState::empty()), + RestoreKeyAction::TypeThrough("a".to_string()) + ); + } + + #[test] + fn control_modified_characters_are_swallowed_not_typed() { + use super::{RestoreKeyAction, restore_key_action}; + use winit::keyboard::{Key, ModifiersState, SmolStr}; + + for ch in ["c", "d", "a"] { + assert_eq!( + restore_key_action(&Key::Character(SmolStr::new(ch)), ModifiersState::CONTROL), + RestoreKeyAction::Swallow, + "Ctrl+{ch} must not type the literal letter" + ); + } + } + + #[test] + fn control_modified_space_is_swallowed_not_typed() { + use super::{RestoreKeyAction, restore_key_action}; + use winit::keyboard::{Key, ModifiersState, NamedKey}; + + assert_eq!( + restore_key_action(&Key::Named(NamedKey::Space), ModifiersState::CONTROL), + RestoreKeyAction::Swallow + ); + } + + #[test] + fn super_modified_characters_are_swallowed_not_typed() { + use super::{RestoreKeyAction, restore_key_action}; + use winit::keyboard::{Key, ModifiersState, SmolStr}; + + // Defense in depth: on the real keyboard path this case can't + // happen (see this section's doc) — `main.rs` never calls + // `restore_key`/`restore_key_action` at all while Super is held. + // The classifier still refuses to type Super-held characters + // through, so any caller that DID somehow reach it this way (e.g. a + // future ctl verb that can attach modifiers) gets the safe answer + // rather than a stray dismiss-and-type. + for ch in ["q", "w", "n", "t"] { + assert_eq!( + restore_key_action(&Key::Character(SmolStr::new(ch)), ModifiersState::SUPER), + RestoreKeyAction::Swallow, + "Super+{ch} must not type the literal letter" + ); + } + } + + // --- find_setting_row_by_label: the resolved-view/static-table lookup -- // --- find_setting_row_by_label: the resolved-view/static-table lookup -- + + #[test] + fn find_setting_row_by_label_locates_and_adjusts_a_representative_cycle_row() { + use super::find_setting_row_by_label; + use ember_core::{Config, RestoreMode, RowKind}; + + let row = find_setting_row_by_label("Session restore").expect("row must be found"); + assert_eq!(row.kind, RowKind::Cycle); + let mut c = Config::default(); + assert_eq!(c.restore.mode, RestoreMode::Ask); // starting point + (row.adjust.expect("cycle row has an adjust fn"))(&mut c, 1.0); + assert_eq!(c.restore.mode, RestoreMode::Always); + } + + #[test] + fn find_setting_row_by_label_locates_and_adjusts_a_representative_toggle_row() { + use super::find_setting_row_by_label; + use ember_core::Config; + + let row = find_setting_row_by_label("Capture commands").expect("row must be found"); + let mut c = Config::default(); + assert!(c.restore.capture_commands); + (row.adjust.expect("toggle row has an adjust fn"))(&mut c, 1.0); + assert!(!c.restore.capture_commands); + } + + #[test] + fn find_setting_row_by_label_misses_cleanly_on_a_synthesized_label() { + use super::find_setting_row_by_label; + + // "Delete saved sessions (N)…" is synthesized by `resolve_rows`, + // never a static-table label — the lookup must return `None`, not + // panic, so `adjust_setting` just skips mutation for it. + assert!(find_setting_row_by_label("Delete saved sessions (4)…").is_none()); + } } diff --git a/crates/ember-core/src/backend.rs b/crates/ember-core/src/backend.rs index 8981632..013b56b 100644 --- a/crates/ember-core/src/backend.rs +++ b/crates/ember-core/src/backend.rs @@ -87,6 +87,9 @@ pub enum OscEvent { CommandStart, OutputStart, CommandEnd(Option), + /// OSC 633;E (VS Code shell integration) — the literal command line the + /// shell is about to run. Emitted by Ember's own injected hooks. + CommandLine(String), // iTerm2 OSC 1337 subset. CurrentDir(String), RemoteHost(String), diff --git a/crates/ember-core/src/config.rs b/crates/ember-core/src/config.rs index 4e41a31..41f792b 100644 --- a/crates/ember-core/src/config.rs +++ b/crates/ember-core/src/config.rs @@ -46,6 +46,11 @@ pub struct Config { /// `wisp` is true. Default `cinder` (the original look, unchanged — /// renamed from `ember` in v0.4.1; the old name still parses). pub wisp_style: WispStyleSelection, + /// Session restore (design doc `2026-08-31-session-restore-design.md`): + /// whether Ember snapshots window/tab/pane state for restore on next + /// launch, whether restoring asks first, and whether shell commands are + /// captured for the restore pre-type. See [`RestoreConfig`]. + pub restore: RestoreConfig, } impl Default for Config { @@ -60,6 +65,54 @@ impl Default for Config { wisp: true, osc52_read: false, wisp_style: WispStyleSelection::Cinder, + restore: RestoreConfig::default(), + } + } +} + +/// Session-restore mode: whether Ember snapshots window/tab/pane state for +/// restore on next launch, and whether restoring is silent or asks first. +/// Serialized lowercase (`"off"` / `"ask"` / `"always"`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RestoreMode { + /// Never snapshot or restore. Existing on-disk session files are left + /// alone — turning this off only stops writing more; deleting what's + /// already saved is the separate, explicit "Delete saved sessions" + /// Settings action. + Off, + /// Snapshot continuously; on launch with a saved session, ask before + /// restoring it. The shipping default: restoring the exact session you + /// left is useful, but reopening N windows unannounced on every launch + /// isn't. + #[default] + Ask, + /// Snapshot continuously; on launch with a saved session, restore it + /// immediately without asking. + Always, +} + +/// Session-restore configuration: the mode dial plus the command-capture +/// kill-switch. `#[serde(default)]` so a `config.toml` predating this +/// feature (no `[restore]` table at all) still loads, picking up +/// `RestoreMode::Ask` + capture-on. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct RestoreConfig { + pub mode: RestoreMode, + /// Capture each pane's last shell command line into the snapshot, for + /// restore's pre-type. On by default. Turning this off keeps + /// layout/cwd restore but immediately strips any commands already + /// written to disk (privacy: command lines can carry inline secrets, + /// e.g. `export TOKEN=...`). + pub capture_commands: bool, +} + +impl Default for RestoreConfig { + fn default() -> Self { + Self { + mode: RestoreMode::Ask, + capture_commands: true, } } } @@ -430,6 +483,33 @@ mod tests { assert_eq!(WispStyleSelection::Goo.resolve(2), WispStyle::Goo); } + // --- restore config: defaults, roundtrip, backcompat -------------------- + + #[test] + fn restore_config_defaults() { + let c: Config = toml::from_str("").unwrap(); + assert_eq!(c.restore.mode, RestoreMode::Ask); + assert!(c.restore.capture_commands); + } + + #[test] + fn restore_config_roundtrips() { + let mut c = Config::default(); + c.restore.mode = RestoreMode::Always; + c.restore.capture_commands = false; + let c2: Config = toml::from_str(&toml::to_string(&c).unwrap()).unwrap(); + assert_eq!(c2.restore.mode, RestoreMode::Always); + assert!(!c2.restore.capture_commands); + } + + #[test] + fn restore_partial_table_fills_the_other_field_from_default() { + // Only `mode` set; `capture_commands` must still default to true. + let c: Config = toml::from_str("[restore]\nmode = \"off\"\n").unwrap(); + assert_eq!(c.restore.mode, RestoreMode::Off); + assert!(c.restore.capture_commands); + } + #[test] fn wisp_style_resolve_random_round_robins_and_varies() { let seen: Vec = (0..6) diff --git a/crates/ember-core/src/lib.rs b/crates/ember-core/src/lib.rs index d333965..c6148f0 100644 --- a/crates/ember-core/src/lib.rs +++ b/crates/ember-core/src/lib.rs @@ -24,7 +24,9 @@ pub use backend::{ frame_channel, }; pub use command::{LayoutCommand, LayoutEffect, apply}; -pub use config::{Background, Config, Font, SparksMode, WispStyle, WispStyleSelection}; +pub use config::{ + Background, Config, Font, RestoreConfig, RestoreMode, SparksMode, WispStyle, WispStyleSelection, +}; pub use focus::{Direction, focus_dir}; pub use geom::Rect; pub use grid::{ diff --git a/crates/ember-core/src/settings.rs b/crates/ember-core/src/settings.rs index 6094583..b87b9c1 100644 --- a/crates/ember-core/src/settings.rs +++ b/crates/ember-core/src/settings.rs @@ -12,7 +12,7 @@ //! row's logic only touches its own `Config` parameter (no captured //! environment), so non-capturing closures coerce to `fn` pointers for free. -use crate::config::{Config, SparksMode, WispStyleSelection}; +use crate::config::{Config, RestoreMode, SparksMode, WispStyleSelection}; /// What kind of row this is, driving both rendering and key-handling. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -28,8 +28,11 @@ pub enum RowKind { Cycle, /// Shown but not adjustable (e.g. the config.toml-only backdrop image). ReadOnly, - /// Triggers an action on Enter/Space rather than adjusting a value. No - /// row uses this yet — reserved for a future "Check for updates" row. + /// Triggers an action on Enter/Space rather than adjusting a value — + /// e.g. "Delete saved sessions (N)…". Has no `Config`-mutating + /// `SettingRow::adjust`: the action isn't a config field, so the app + /// layer (`ember-app`'s `settings_key`) handles it by matching on this + /// kind rather than calling `adjust`. Action, /// A category divider: not selectable, skipped by Up/Down navigation. SectionHeader, @@ -61,24 +64,63 @@ pub struct SettingRow { /// the `ember-app`/`ember-render` boundary — `ember-app` builds these each /// time the overlay needs a repaint (via [`resolve_rows`]), `ember-render` /// only ever sees already-formatted strings + the row's `kind`. +/// +/// `label` is owned (not `&'static str` like `SettingRow::label`) because +/// one synthesized row — "Delete saved sessions (N)…" — bakes a live count +/// into its label text; every other row's label is just its static +/// `SettingRow::label` copied in. #[derive(Clone, Debug, PartialEq, Eq)] pub struct SettingsRowView { - pub label: &'static str, + pub label: String, pub value: String, pub kind: RowKind, } +/// The "Session restore" cycle row's static label — also the anchor +/// `resolve_rows` inserts the synthesized "Delete saved sessions" row +/// after, so the two can't drift apart. +const SESSION_RESTORE_LABEL: &str = "Session restore"; +/// The "Capture commands" toggle row's static label — also what +/// `resolve_rows` matches on to hide the row while `restore.mode == Off` +/// (nothing to capture) and to anchor the delete row's insertion point when +/// it's visible. +const CAPTURE_COMMANDS_LABEL: &str = "Capture commands"; + /// Resolve every row in [`setting_rows`] against `config` into render-ready -/// views, in table order. -pub fn resolve_rows(config: &Config) -> Vec { - setting_rows() - .iter() - .map(|r| SettingsRowView { - label: r.label, +/// views, in table order — plus the "Delete saved sessions (N)…" action row +/// synthesized right after the Session-restore rows (it isn't a `Config` +/// field, so it has no entry in [`setting_rows`]'s static table). +/// +/// `saved_sessions` is the count of on-disk session-state files (the live +/// snapshot, its quarantined-corrupt sibling, and every `.prev-*` archive — +/// `ember-app`'s `session_state::saved_state_count`); the delete row is +/// omitted entirely when it's `0` (nothing to delete). `Capture commands` is +/// likewise omitted while `config.restore.mode == RestoreMode::Off` — there +/// is nothing to capture, and no `SettingRow::adjust` fn for it to mutate +/// meaningfully in that state. +pub fn resolve_rows(config: &Config, saved_sessions: usize) -> Vec { + let mut rows = Vec::new(); + for r in setting_rows() { + if r.label == CAPTURE_COMMANDS_LABEL && config.restore.mode == RestoreMode::Off { + continue; + } + rows.push(SettingsRowView { + label: r.label.to_string(), value: (r.format)(config), kind: r.kind, - }) - .collect() + }); + let is_last_session_row = (r.label == SESSION_RESTORE_LABEL + && config.restore.mode == RestoreMode::Off) + || r.label == CAPTURE_COMMANDS_LABEL; + if is_last_session_row && saved_sessions > 0 { + rows.push(SettingsRowView { + label: format!("Delete saved sessions ({saved_sessions})…"), + value: String::new(), + kind: RowKind::Action, + }); + } + } + rows } fn on_off(b: bool) -> String { @@ -260,6 +302,33 @@ fn adjust_option_as_meta(c: &mut Config, _dir: f32) { c.option_as_meta = !c.option_as_meta; } +// --- Session restore --------------------------------------------------------- + +/// The restore-mode dial's three-state cycle, `off → ask → always → off`. +/// Direction-agnostic (like the sparks/wisp-style dials): always steps +/// forward regardless of `dir`. +fn fmt_restore_mode(c: &Config) -> String { + match c.restore.mode { + RestoreMode::Off => "off".to_string(), + RestoreMode::Ask => "ask".to_string(), + RestoreMode::Always => "always".to_string(), + } +} +fn adjust_restore_mode(c: &mut Config, _dir: f32) { + c.restore.mode = match c.restore.mode { + RestoreMode::Off => RestoreMode::Ask, + RestoreMode::Ask => RestoreMode::Always, + RestoreMode::Always => RestoreMode::Off, + }; +} + +fn fmt_capture_commands(c: &Config) -> String { + on_off(c.restore.capture_commands) +} +fn adjust_capture_commands(c: &mut Config, _dir: f32) { + c.restore.capture_commands = !c.restore.capture_commands; +} + // --- Developer ----------------------------------------------------------------- fn fmt_developer_mode(c: &Config) -> String { @@ -400,6 +469,35 @@ pub fn setting_rows() -> &'static [SettingRow] { characters. Takes effect immediately.", ), }, + SettingRow { + label: "Session", + kind: RowKind::SectionHeader, + format: |_| String::new(), + adjust: None, + help: Help::Inline(""), + }, + SettingRow { + label: SESSION_RESTORE_LABEL, + kind: RowKind::Cycle, + format: fmt_restore_mode, + adjust: Some(adjust_restore_mode), + help: Help::Inline( + "Snapshot open windows/tabs/splits and offer to restore them on next launch: \ + off, ask first (default), or always restore silently. Off leaves any \ + already-saved session on disk; delete it with the row below.", + ), + }, + SettingRow { + label: CAPTURE_COMMANDS_LABEL, + kind: RowKind::Toggle, + format: fmt_capture_commands, + adjust: Some(adjust_capture_commands), + help: Help::Inline( + "Capture each pane's last shell command line so restore can re-type it \ + (unsent) at the prompt. Off keeps layout/cwd restore but immediately clears \ + any commands already saved.", + ), + }, SettingRow { label: "Developer", kind: RowKind::SectionHeader, @@ -718,12 +816,131 @@ mod tests { } #[test] - fn category_order_is_appearance_terminal_developer() { + fn category_order_is_appearance_terminal_session_developer() { let headers: Vec<&str> = setting_rows() .iter() .filter(|r| r.kind == RowKind::SectionHeader) .map(|r| r.label) .collect(); - assert_eq!(headers, vec!["Appearance", "Terminal", "Developer"]); + assert_eq!( + headers, + vec!["Appearance", "Terminal", "Session", "Developer"] + ); + } + + // --- session restore: config rows, hide/show, delete-action synthesis --- + + #[test] + fn settings_include_restore_rows() { + let rows = resolve_rows(&Config::default(), 0); + assert!(rows.iter().any(|r| r.label.starts_with("Session restore"))); + assert!(rows.iter().any(|r| r.label.starts_with("Capture commands"))); + } + + #[test] + fn session_restore_row_is_a_cycle() { + assert_eq!(row(SESSION_RESTORE_LABEL).kind, RowKind::Cycle); + } + + #[test] + fn session_restore_cycle_visits_all_three_states_and_wraps() { + let mut c = Config::default(); + c.restore.mode = RestoreMode::Off; + let adjust = row(SESSION_RESTORE_LABEL).adjust.unwrap(); + adjust(&mut c, 1.0); + assert_eq!(c.restore.mode, RestoreMode::Ask); + adjust(&mut c, 1.0); + assert_eq!(c.restore.mode, RestoreMode::Always); + adjust(&mut c, 1.0); + assert_eq!(c.restore.mode, RestoreMode::Off); + } + + #[test] + fn session_restore_cycle_mutates_only_restore_mode() { + let mut c = Config::default(); + let before = c.clone(); + (row(SESSION_RESTORE_LABEL).adjust.unwrap())(&mut c, 1.0); + assert_ne!(c.restore.mode, before.restore.mode); + assert_eq!(c.restore.capture_commands, before.restore.capture_commands); + assert_eq!(c.developer_mode, before.developer_mode); + } + + #[test] + fn capture_commands_row_is_a_toggle() { + assert_eq!(row(CAPTURE_COMMANDS_LABEL).kind, RowKind::Toggle); + } + + #[test] + fn capture_commands_toggle_mutates_only_capture_commands() { + let mut c = Config::default(); + let before = c.clone(); + (row(CAPTURE_COMMANDS_LABEL).adjust.unwrap())(&mut c, 1.0); + assert_ne!(c.restore.capture_commands, before.restore.capture_commands); + assert_eq!(c.restore.mode, before.restore.mode); + } + + #[test] + fn capture_commands_row_hidden_when_restore_is_off() { + let mut c = Config::default(); + c.restore.mode = RestoreMode::Off; + let rows = resolve_rows(&c, 0); + assert!(!rows.iter().any(|r| r.label == CAPTURE_COMMANDS_LABEL)); + } + + #[test] + fn capture_commands_row_shown_when_restore_is_ask_or_always() { + for mode in [RestoreMode::Ask, RestoreMode::Always] { + let mut c = Config::default(); + c.restore.mode = mode; + let rows = resolve_rows(&c, 0); + assert!( + rows.iter().any(|r| r.label == CAPTURE_COMMANDS_LABEL), + "capture commands row missing for mode {mode:?}" + ); + } + } + + #[test] + fn delete_saved_sessions_row_hidden_when_count_is_zero() { + let rows = resolve_rows(&Config::default(), 0); + assert!( + !rows + .iter() + .any(|r| r.label.starts_with("Delete saved sessions")) + ); + } + + #[test] + fn delete_saved_sessions_row_shown_with_count_in_label_when_nonzero() { + let rows = resolve_rows(&Config::default(), 4); + let r = rows + .iter() + .find(|r| r.label.starts_with("Delete saved sessions")) + .expect("delete row present"); + assert_eq!(r.label, "Delete saved sessions (4)…"); + assert_eq!(r.kind, RowKind::Action); + } + + #[test] + fn delete_saved_sessions_row_shown_even_when_restore_is_off() { + // Off keeps files on disk (ruling) — the delete action must still be + // reachable to clear them. + let mut c = Config::default(); + c.restore.mode = RestoreMode::Off; + let rows = resolve_rows(&c, 2); + assert!(rows.iter().any(|r| r.label == "Delete saved sessions (2)…")); + } + + #[test] + fn action_rows_have_no_adjust() { + for r in setting_rows() { + if r.kind == RowKind::Action { + assert!( + r.adjust.is_none(), + "{:?} action row must not be adjustable", + r.label + ); + } + } } } diff --git a/crates/ember-render/examples/sprite_smoke.rs b/crates/ember-render/examples/sprite_smoke.rs index 0df0f4d..cabf8a1 100644 --- a/crates/ember-render/examples/sprite_smoke.rs +++ b/crates/ember-render/examples/sprite_smoke.rs @@ -88,6 +88,7 @@ fn main() { font_size: 12.0, font_family: None, confirm: None, + restore: None, hold_ring: None, ghost_tab: None, morph: None, diff --git a/crates/ember-render/src/headless.rs b/crates/ember-render/src/headless.rs index 571904c..b0add84 100644 --- a/crates/ember-render/src/headless.rs +++ b/crates/ember-render/src/headless.rs @@ -22,14 +22,14 @@ use crate::background::{ImageRenderer, SparkRenderer}; use crate::grid_model::GridModel; use crate::paint::{ AboutLayout, bell_wash, build_about, build_confirm, build_fps, build_help, build_ime_preedit, - build_palette, build_search_bar, build_settings, build_tabs, grid_quads, hold_ring_quads, - link_quads, measure_cell_width, morph_quads, push_backdrop, scrollbar, selection_quads, - shape_grid, spark_quads, split_preview, + build_palette, build_restore_list, build_restore_main, build_search_bar, build_settings, + build_tabs, grid_quads, hold_ring_quads, link_quads, measure_cell_width, morph_quads, + push_backdrop, scrollbar, selection_quads, shape_grid, spark_quads, split_preview, }; use crate::quads::{QuadRenderer, srgb_to_linear}; use crate::renderer::{ ABOUT_TITLE_LINE, ABOUT_TITLE_SIZE, AMBER, AboutInfo, BG, BackdropParams, FG, FONT_SIZE, - HELP_PAD, ImageFit, LINE_HEIGHT, PAD, TabLabel, + HELP_PAD, ImageFit, LINE_HEIGHT, PAD, RestoreView, TabLabel, }; use crate::selection::Selection; @@ -87,6 +87,9 @@ pub struct Shot<'a> { pub font_family: Option, /// A blocking confirm modal drawn over everything, if shown. pub confirm: Option, + /// The restore-on-launch modal (Task 8), drawn over everything if shown. + /// Mutually exclusive with `confirm` in practice. + pub restore: Option, /// Hold-to-wisp ring (v1.1): `(logical x, logical y, progress 0..1)` — /// mirrors [`crate::Renderer`]'s live `hold_ring` state so a mid-gesture /// `ctl screenshot` shows the sweep for visual verification. @@ -270,6 +273,15 @@ pub fn capture_reusing( let mut cf_cancel = Buffer::new(font_system, Metrics::new(FONT_SIZE, LINE_HEIGHT)); let mut cf_ok = Buffer::new(font_system, Metrics::new(FONT_SIZE, LINE_HEIGHT)); let mut confirm_layout: Option = None; + let mut restore_header = Buffer::new(font_system, Metrics::new(FONT_SIZE, LINE_HEIGHT)); + let mut restore_buttons = [ + Buffer::new(font_system, Metrics::new(FONT_SIZE, LINE_HEIGHT)), + Buffer::new(font_system, Metrics::new(FONT_SIZE, LINE_HEIGHT)), + Buffer::new(font_system, Metrics::new(FONT_SIZE, LINE_HEIGHT)), + ]; + let mut restore_list = Buffer::new(font_system, Metrics::new(FONT_SIZE, LINE_HEIGHT)); + let mut restore_main_layout: Option = None; + let mut restore_list_origin: Option<(f32, f32)> = None; let mut rects: Vec<([f32; 4], [f32; 4])> = Vec::new(); let mut rounded: Vec<([f32; 4], [f32; 4], f32)> = Vec::new(); let mut spark_rects: Vec<([f32; 4], [f32; 4])> = Vec::new(); @@ -546,6 +558,44 @@ pub fn capture_reusing( &mut rounded, )); } + // Restore-on-launch modal (Task 8) — same overlay-pass layering as the + // confirm modal just above; mirrors the live renderer's `render` exactly + // so a `--screenshot` capture matches on-screen pixel-for-pixel. + match &shot.restore { + Some(RestoreView::Main { header, focused }) => { + restore_main_layout = Some(build_restore_main( + font_system, + &mut restore_header, + &mut restore_buttons, + header, + *focused, + cw, + shot.logical_w, + shot.logical_h, + sf, + &mut rounded, + )); + } + Some(RestoreView::Older { + header, + rows, + selected, + }) => { + restore_list_origin = Some(build_restore_list( + font_system, + &mut restore_list, + header, + rows, + *selected, + cw, + shot.logical_w, + shot.logical_h, + sf, + &mut rounded, + )); + } + None => {} + } quads.prepare( device, queue, @@ -725,6 +775,39 @@ pub fn capture_reusing( }); } } + if let Some(rl) = &restore_main_layout { + overlay_areas.push(TextArea { + buffer: &restore_header, + left: rl.header_origin.0 * sf, + top: rl.header_origin.1 * sf, + scale: sf, + bounds: full_bounds, + default_color: Color::rgb(FG.r, FG.g, FG.b), + custom_glyphs: &[], + }); + for (buf, (ox, oy)) in restore_buttons.iter().zip(rl.button_origins) { + overlay_areas.push(TextArea { + buffer: buf, + left: ox * sf, + top: oy * sf, + scale: sf, + bounds: full_bounds, + default_color: Color::rgb(FG.r, FG.g, FG.b), + custom_glyphs: &[], + }); + } + } + if let Some((left, top)) = restore_list_origin { + overlay_areas.push(TextArea { + buffer: &restore_list, + left: left * sf, + top: top * sf, + scale: sf, + bounds: full_bounds, + default_color: Color::rgb(0xf5, 0xf5, 0xdc), + custom_glyphs: &[], + }); + } text_renderer .prepare_with_custom( device, diff --git a/crates/ember-render/src/lib.rs b/crates/ember-render/src/lib.rs index 1f79520..89c6d6b 100644 --- a/crates/ember-render/src/lib.rs +++ b/crates/ember-render/src/lib.rs @@ -22,7 +22,7 @@ pub use grid_model::{GridModel, LinkSource, LinkSpan}; pub use headless::CaptureError; pub use renderer::{ AboutInfo, BackdropParams, CELL_HEIGHT, CELL_WIDTH, ConfirmView, ImageFit, PaneModes, - PaneSnapshot, RenderOutcome, Renderer, StripSlot, TabHit, TabLabel, VisiblePane, + PaneSnapshot, RenderOutcome, Renderer, RestoreView, StripSlot, TabHit, TabLabel, VisiblePane, }; pub use selection::{AbsPoint, AnchoredSelection, Point, Selection, SelectionMode}; pub use wisp::{WispRenderer, WispUnsupported}; diff --git a/crates/ember-render/src/paint.rs b/crates/ember-render/src/paint.rs index a5e8850..52c565a 100644 --- a/crates/ember-render/src/paint.rs +++ b/crates/ember-render/src/paint.rs @@ -1482,10 +1482,16 @@ pub(crate) fn build_settings( }; spans.push((text, color)); } - spans.push(( - "\n↑/↓ select ←/→ change esc close".to_string(), - Color::rgb(0x80, 0x80, 0x80), - )); + // The action row ("Delete saved sessions…") only fires on Enter/Space, + // never Left/Right (see `settings_action_for_key` in `ember-app`) — the + // footer hint reflects that instead of advertising a "change" arrows + // hint that would do nothing on that row. + let hint = if rows.get(selected).map(|r| r.kind) == Some(RowKind::Action) { + "\n↑/↓ select enter/space activate esc close" + } else { + "\n↑/↓ select ←/→ change esc close" + }; + spans.push((hint.to_string(), Color::rgb(0x80, 0x80, 0x80))); buf.set_rich_text( font_system, spans @@ -1965,6 +1971,214 @@ pub(crate) fn build_confirm( } } +/// Text-placement result from [`build_restore_main`] (logical px). +pub(crate) struct RestoreMainLayout { + pub header_origin: (f32, f32), + /// `[Restore, Start fresh, Older…]` label text origins, in that order. + pub button_origins: [(f32, f32); 3], +} + +/// The restore-modal Main screen's three fixed button labels, in `focused` +/// index order — shared by the draw code here and `WindowState`'s key +/// handling (Left/Right/Tab cycles `focused` through these same 3 slots). +pub(crate) const RESTORE_MAIN_LABELS: [&str; 3] = ["Restore", "Start fresh", "Older…"]; + +/// Draw the restore-on-launch modal's Main screen: a scrim + centered +/// rounded panel with the header line ("Restore N windows, M tabs (from +/// )?") and three buttons (Restore / Start fresh / Older…). Mirrors +/// [`build_confirm`]'s panel/button visual language (ember ring on the +/// focused button, same scrim/panel colors) generalized from two buttons to +/// three; the first (Restore) is ember-tinted as the primary action instead +/// of only the confirm slot being tinted. Everything rides `rounded` so the +/// modal draws over all content, same layering rule as `build_confirm`. +#[allow(clippy::too_many_arguments)] +pub(crate) fn build_restore_main( + font_system: &mut FontSystem, + header_buf: &mut Buffer, + btn_bufs: &mut [Buffer; 3], + header: &str, + focused: usize, + cw: f32, + logical_w: f32, + logical_h: f32, + sf: f32, + rounded: &mut Vec<([f32; 4], [f32; 4], f32)>, +) -> RestoreMainLayout { + let pad = 20.0; + let btn_h = 30.0; + let btn_gap = 10.0; + let label_w = |s: &str| s.chars().count() as f32 * cw + 26.0; + let widths: [f32; 3] = [ + label_w(RESTORE_MAIN_LABELS[0]).max(72.0), + label_w(RESTORE_MAIN_LABELS[1]).max(72.0), + label_w(RESTORE_MAIN_LABELS[2]).max(72.0), + ]; + let btn_total_w = widths[0] + widths[1] + widths[2] + btn_gap * 2.0; + let header_w_needed = header.chars().count() as f32 * cw + 2.0 * pad; + let cap = (logical_w - 24.0).max(btn_total_w + 2.0 * pad); + let w = (logical_w * 0.6) + .clamp(380.0, 620.0) + .max(btn_total_w + 2.0 * pad) + .max(header_w_needed) + .min(cap); + let h = pad + LINE_HEIGHT + 18.0 + btn_h + pad; + let x = ((logical_w - w) * 0.5).max(0.0); + let y = ((logical_h - h) * 0.5).max(4.0); + + // Scrim (radius 0), then the panel (rounded, ember ring border). + rounded.push(( + scaled(0.0, 0.0, logical_w, logical_h, sf), + lin_rgba(Rgb::new(0, 0, 0), 0.62), + 0.0, + )); + let r = 10.0; + rounded.push(( + scaled(x - 1.5, y - 1.5, w + 3.0, h + 3.0, sf), + lin_rgba(ACCENT, 0.9), + (r + 1.5) * sf, + )); + rounded.push(( + scaled(x, y, w, h, sf), + lin_rgba(Rgb::new(0x20, 0x22, 0x28), 1.0), + r * sf, + )); + + // Buttons: centered as one row, Restore first (leftmost, primary/ember-tinted). + let by = y + h - pad - btn_h; + let start_x = x + (w - btn_total_w) * 0.5; + let mut bx = start_x; + let mut origins = [(0.0, 0.0); 3]; + for (i, &bw) in widths.iter().enumerate() { + if focused == i { + rounded.push(( + scaled(bx - 1.5, by - 1.5, bw + 3.0, btn_h + 3.0, sf), + lin_rgba(Rgb::new(0xff, 0xff, 0xff), 0.8), + (8.0 + 1.5) * sf, + )); + } + let (bg, alpha) = if i == 0 { + (ACCENT, 0.92) // Restore is the primary action. + } else { + (Rgb::new(0x3a, 0x3a, 0x3d), 1.0) + }; + rounded.push((scaled(bx, by, bw, btn_h, sf), lin_rgba(bg, alpha), 8.0 * sf)); + let label = RESTORE_MAIN_LABELS[i]; + origins[i] = ( + bx + (bw - label.chars().count() as f32 * cw) * 0.5, + by + (btn_h - LINE_HEIGHT) * 0.5, + ); + bx += bw + btn_gap; + } + + let shape = |fs: &mut FontSystem, buf: &mut Buffer, text: &str, color: Color, width: f32| { + buf.set_size(fs, Some(width), Some(LINE_HEIGHT)); + buf.set_text( + fs, + text, + &Attrs::new().family(Family::Monospace).color(color), + Shaping::Advanced, + None, + ); + buf.shape_until_scroll(fs, false); + }; + shape( + font_system, + header_buf, + header, + Color::rgb(0xff, 0xff, 0xff), + w - 2.0 * pad, + ); + for (i, buf) in btn_bufs.iter_mut().enumerate() { + let color = if i == 0 { + Color::rgb(0xff, 0xff, 0xff) + } else { + Color::rgb(0xf0, 0xf0, 0xf0) + }; + shape(font_system, buf, RESTORE_MAIN_LABELS[i], color, widths[i]); + } + + RestoreMainLayout { + header_origin: (x + pad, y + pad), + button_origins: origins, + } +} + +/// Draw the restore-on-launch modal's `Older…` screen: a scrim + centered +/// panel listing up to 10 archived snapshots (`rows`, already formatted as +/// `" · N windows, M tabs"`), the header question kept on screen above +/// them, and a highlight on `selected`. Structurally `build_palette` minus +/// the live query line — same single-multi-line-buffer/selected-row-highlight +/// shape, just seeded with a static header instead of live query text. +/// Returns the text origin for the caller's one `TextArea`. +#[allow(clippy::too_many_arguments)] +pub(crate) fn build_restore_list( + font_system: &mut FontSystem, + buf: &mut Buffer, + header: &str, + rows: &[String], + selected: usize, + cw: f32, + logical_w: f32, + logical_h: f32, + sf: f32, + rounded: &mut Vec<([f32; 4], [f32; 4], f32)>, +) -> (f32, f32) { + let ipad = 10.0; + let cols = 56usize; + let w = (cols as f32 * cw + 2.0 * ipad).min(logical_w - 24.0); + // At least one row of height so an empty archive list still shows its + // "(no archived sessions)" line (an invisible captured overlay reads as + // "the app stopped responding"). + let shown = rows.len().clamp(1, 10); + let h = (shown as f32 + 2.0) * LINE_HEIGHT + 2.0 * ipad; + let x = ((logical_w - w) * 0.5).max(0.0); + let y = (logical_h * 0.18).max(44.0); + + rounded.push(( + scaled(0.0, 0.0, logical_w, logical_h, sf), + lin_rgba(Rgb::new(0, 0, 0), 0.62), + 0.0, + )); + rounded.push(( + scaled(x - 1.0, y - 1.0, w + 2.0, h + 2.0, sf), + lin_rgba(ACCENT, 0.9), + 0.0, + )); + rounded.push(( + scaled(x, y, w, h, sf), + lin_rgba(Rgb::new(0x20, 0x22, 0x28), 1.0), + 0.0, + )); + if !rows.is_empty() && selected < shown { + let ry = y + ipad + (selected as f32 + 2.0) * LINE_HEIGHT; + rounded.push(( + scaled(x + 3.0, ry, w - 6.0, LINE_HEIGHT, sf), + lin_rgba(ACCENT, 0.28), + 0.0, + )); + } + let mut text = format!("{header}\n\n"); + for r in rows.iter().take(shown) { + text.push_str(r); + text.push('\n'); + } + if rows.is_empty() { + text.push_str("(no archived sessions)\n"); + } + buf.set_size(font_system, Some(w - 2.0 * ipad), Some(h)); + buf.set_text( + font_system, + &text, + &Attrs::new() + .family(Family::Monospace) + .color(Color::rgb(0xf5, 0xf5, 0xdc)), + Shaping::Advanced, + None, + ); + buf.shape_until_scroll(font_system, false); + (x + ipad, y + ipad) +} + #[cfg(test)] mod tests { use super::center; diff --git a/crates/ember-render/src/renderer.rs b/crates/ember-render/src/renderer.rs index 73b3417..ea5b616 100644 --- a/crates/ember-render/src/renderer.rs +++ b/crates/ember-render/src/renderer.rs @@ -30,9 +30,10 @@ use crate::background::{ImageRenderer, SparkRenderer}; use crate::grid_model::GridModel; use crate::paint::{ BTN_COLS, CLOSE_COLS, bell_wash, build_about, build_confirm, build_fps, build_help, - build_ime_preedit, build_palette, build_search_bar, build_settings, build_tabs, debug_emit, - grid_quads, hold_ring_quads, measure_cell_width, morph_quads, push_backdrop, scrollbar, - scrollbar_geometry, selection_quads, shape_grid, spark_quads, split_preview, + build_ime_preedit, build_palette, build_restore_list, build_restore_main, build_search_bar, + build_settings, build_tabs, debug_emit, grid_quads, hold_ring_quads, measure_cell_width, + morph_quads, push_backdrop, scrollbar, scrollbar_geometry, selection_quads, shape_grid, + spark_quads, split_preview, }; use crate::selection::AnchoredSelection; @@ -101,6 +102,28 @@ pub struct ConfirmView { pub focused: usize, } +/// The restore-on-launch modal (Task 8): either the Main screen's +/// Restore/Start fresh/Older… choice, or the Older… archive list. +/// Render-ready (owns its own pre-formatted, humanized strings) so the +/// renderer never has to reach into `session_state` types directly — +/// `WindowState` builds this from its `RestorePrompt` + humanized ages. +#[derive(Clone, Debug)] +pub enum RestoreView { + /// `header`: e.g. `"Restore 2 windows, 3 tabs (from 2h ago)?"`. + /// `focused`: which of the 3 fixed buttons (`RESTORE_MAIN_LABELS` + /// order: Restore / Start fresh / Older…) is highlighted. + Main { header: String, focused: usize }, + /// `header`: the same restore question, kept on screen while browsing. + /// `rows`: one already-formatted `" · N windows, M tabs"` string + /// per archive, newest first (at most 10 — the list is capped there + /// well before this type is built). `selected`: the highlighted row. + Older { + header: String, + rows: Vec, + selected: usize, + }, +} + /// Static content for the About overlay (the animated glow is separate). #[derive(Clone, Debug)] pub struct AboutInfo { @@ -570,6 +593,19 @@ pub struct Renderer { /// The confirm modal's `[cancel, confirm]` button rects (logical px), for /// hit-testing clicks. Empty when the modal is hidden. confirm_buttons: Vec<([f32; 4], usize)>, + /// When `Some`, the restore-on-launch modal is shown (Task 8) — mutually + /// exclusive with `confirm` in practice (never set both), but not + /// enforced structurally, same as `settings`/`about`/`help` aren't + /// enforced against each other either; the draw order below picks one. + restore: Option, + /// Header buffer, shared by both restore screens (Main's question line + /// and Older…'s kept-on-screen header). + restore_header: Buffer, + /// Main screen's 3 fixed button-label buffers (Restore/Start fresh/Older…). + restore_buttons: [Buffer; 3], + /// Older… screen's single multi-line buffer (header + up to 10 rows), + /// same one-buffer-per-panel shape as the command palette. + restore_list: Buffer, /// Measured monospace advance (px) — keeps bg quads aligned with glyphs. cell_w: f32, /// Current terminal font point size (mutated by live zoom). @@ -734,6 +770,13 @@ impl Renderer { let confirm_msg = Buffer::new(&mut font_system, Metrics::new(FONT_SIZE, LINE_HEIGHT)); let confirm_cancel = Buffer::new(&mut font_system, Metrics::new(FONT_SIZE, LINE_HEIGHT)); let confirm_ok = Buffer::new(&mut font_system, Metrics::new(FONT_SIZE, LINE_HEIGHT)); + let restore_header = Buffer::new(&mut font_system, Metrics::new(FONT_SIZE, LINE_HEIGHT)); + let restore_buttons = [ + Buffer::new(&mut font_system, Metrics::new(FONT_SIZE, LINE_HEIGHT)), + Buffer::new(&mut font_system, Metrics::new(FONT_SIZE, LINE_HEIGHT)), + Buffer::new(&mut font_system, Metrics::new(FONT_SIZE, LINE_HEIGHT)), + ]; + let restore_list = Buffer::new(&mut font_system, Metrics::new(FONT_SIZE, LINE_HEIGHT)); let fps_buffer = Buffer::new(&mut font_system, Metrics::new(FONT_SIZE, LINE_HEIGHT)); let search_buffer = Buffer::new(&mut font_system, Metrics::new(FONT_SIZE, LINE_HEIGHT)); let palette_buffer = Buffer::new(&mut font_system, Metrics::new(FONT_SIZE, LINE_HEIGHT)); @@ -795,6 +838,10 @@ impl Renderer { confirm_cancel, confirm_ok, confirm_buttons: Vec::new(), + restore: None, + restore_header, + restore_buttons, + restore_list, cell_w, font_size, line_height, @@ -993,6 +1040,7 @@ impl Renderer { font_size: self.font_size, font_family: self.family_name.clone(), confirm: self.confirm.clone(), + restore: self.restore.clone(), hold_ring: self.hold_ring, ghost_tab: self .ghost_tab @@ -1369,6 +1417,16 @@ impl Renderer { }) } + /// Show/hide the restore-on-launch modal (Task 8). + pub fn set_restore(&mut self, view: Option) { + self.scene_dirty = true; + self.restore = view; + } + + pub fn restore_shown(&self) -> bool { + self.restore.is_some() + } + pub fn set_about(&mut self, info: Option) { self.scene_dirty = true; if info.is_none() { @@ -2085,6 +2143,80 @@ impl Renderer { } } + // Restore-on-launch modal (Task 8) — same overlay-pass layering + // as the confirm modal just above (opaque panel into `rounded`, + // text into `overlay_areas` so it can't be overpainted by pane + // glyphs underneath). Mutually exclusive with `confirm` in + // practice. + if let Some(view) = self.restore.clone() { + let lw = self.config.width as f32 / sf; + let lh = self.config.height as f32 / sf; + let cw = self.cell_w; + match view { + RestoreView::Main { header, focused } => { + let rl = build_restore_main( + &mut self.font_system, + &mut self.restore_header, + &mut self.restore_buttons, + &header, + focused, + cw, + lw, + lh, + sf, + &mut rounded, + ); + overlay_areas.push(TextArea { + buffer: &self.restore_header, + left: rl.header_origin.0 * sf, + top: rl.header_origin.1 * sf, + scale: sf, + bounds: full_bounds, + default_color: Color::rgb(FG.r, FG.g, FG.b), + custom_glyphs: &[], + }); + for (buf, (ox, oy)) in self.restore_buttons.iter().zip(rl.button_origins) { + overlay_areas.push(TextArea { + buffer: buf, + left: ox * sf, + top: oy * sf, + scale: sf, + bounds: full_bounds, + default_color: Color::rgb(FG.r, FG.g, FG.b), + custom_glyphs: &[], + }); + } + } + RestoreView::Older { + header, + rows, + selected, + } => { + let (left, top) = build_restore_list( + &mut self.font_system, + &mut self.restore_list, + &header, + &rows, + selected, + cw, + lw, + lh, + sf, + &mut rounded, + ); + overlay_areas.push(TextArea { + buffer: &self.restore_list, + left: left * sf, + top: top * sf, + scale: sf, + bounds: full_bounds, + default_color: Color::rgb(0xf5, 0xf5, 0xdc), + custom_glyphs: &[], + }); + } + } + } + self.quads.prepare( &self.device, &self.queue, diff --git a/crates/ember-session/src/lib.rs b/crates/ember-session/src/lib.rs index b2f97c9..a0b4985 100644 --- a/crates/ember-session/src/lib.rs +++ b/crates/ember-session/src/lib.rs @@ -7,6 +7,7 @@ pub mod local_pty; pub mod osc133; pub mod osc1337; +pub mod osc633; pub mod palette; pub mod projection; pub mod shell_integration; diff --git a/crates/ember-session/src/osc633.rs b/crates/ember-session/src/osc633.rs new file mode 100644 index 0000000..21d2670 --- /dev/null +++ b/crates/ember-session/src/osc633.rs @@ -0,0 +1,226 @@ +//! VS Code shell-integration OSC 633;E command-line scanner. +//! +//! Mirrors [`crate::osc133`]'s scan-and-resync approach (split-across-reads +//! carry, malformed-mid-buffer resync) rather than sharing code with it, for +//! the same reason `osc1337` does: the scanners are short, independently +//! tested, and diverging here can't regress an already-hardened path. +//! +//! Sequence shape: `ESC ] 633 ; [;params…] (BEL | ESC \)`. VS Code +//! emits several subcommands (`A`/`B`/`C`/`D`/`P`/…) for prompt/command +//! lifecycle and property reporting; only `E;` — the shell +//! echoing the command it's about to run — is tracked here. The command line +//! is VS Code-escaped (`\\` for a literal backslash, `\xHH` for byte `HH`) so +//! that embedded semicolons, newlines, and control bytes survive the OSC +//! parameter boundary; [`decode`] reverses that escaping. + +/// A parsed OSC 633 sequence (the tracked subset). +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum Osc633 { + /// `E;` — the command the shell is about to run, decoded. + CommandLine(String), +} + +const PREFIX: &[u8] = b"\x1b]633;"; + +/// Longest sequence we'll treat as "possibly split across reads". Command +/// lines run longer than OSC 133 marks; the 1 KiB storage cap on a captured +/// command line is applied later, at capture — this bound only distinguishes +/// a plausible split from binary noise that happens to contain the prefix. +const MAX_SEQ: usize = 4096; + +/// One scan pass: the complete marks found, plus where a **possibly split** +/// sequence starts at the end of the buffer, so the caller can carry those +/// bytes into the next read. +#[derive(Debug, Default)] +pub struct ScanResult { + /// Each mark, paired with the byte index just past its terminator. + pub marks: Vec<(usize, Osc633)>, + /// Start of an incomplete suffix to carry into the next read, if any. + pub incomplete: Option, +} + +/// Full scan with split-detection — see the module doc for the shape this +/// mirrors ([`crate::osc133::scan_split`]). +pub fn scan_split(bytes: &[u8]) -> ScanResult { + let mut out = ScanResult::default(); + let len = bytes.len(); + let mut i = 0usize; + while i < len { + if bytes[i] != 0x1b { + i += 1; + continue; + } + // How much of the prefix is present starting here? + let n = PREFIX.len().min(len - i); + if bytes[i..i + n] != PREFIX[..n] { + i += 1; + continue; + } + if n < PREFIX.len() { + // A prefix fragment ends the buffer — possibly split across reads. + out.incomplete = Some(i); + break; + } + let start = i + PREFIX.len(); + // Find the terminator: BEL (0x07) or ST (ESC \). + let mut j = start; + let mut term: Option = None; + let mut malformed = false; + while j < len { + if j - start > MAX_SEQ { + malformed = true; // unterminated garbage, not a split + break; + } + match bytes[j] { + 0x07 => { + term = Some(j); + break; + } + 0x1b if j + 1 < len && bytes[j + 1] == 0x5c => { + term = Some(j); + break; + } + // A bare ESC mid-buffer is malformed — but it may START a new + // sequence (or split ST at the very end, handled below). + 0x1b if j + 1 < len => { + malformed = true; + break; + } + _ => j += 1, + } + } + match (term, malformed) { + (Some(t), _) => { + let past = if bytes[t] == 0x07 { t + 1 } else { t + 2 }; + if let Some(ev) = parse_body(&bytes[start..t]) { + out.marks.push((past, ev)); + } + i = past; + } + (None, true) => { + // Resync AT the offending byte — it may begin a new prefix. + i = j.max(i + 1); + } + (None, false) => { + // Ran off the end of the buffer inside a plausible sequence + // (including a trailing lone ESC of a split ST). + out.incomplete = Some(i); + break; + } + } + } + out +} + +/// Parse the bytes between the `633;` prefix and the terminator. Only the +/// `E;` subcommand produces a mark; every other letter (`A`, +/// `B`, `C`, `D`, `P`, …) is part of the protocol but not tracked here. +fn parse_body(body: &[u8]) -> Option { + if body.first() != Some(&b'E') || body.get(1) != Some(&b';') { + return None; + } + Some(Osc633::CommandLine(decode(&body[2..]))) +} + +/// Reverse VS Code's command-line escaping: `\\` decodes to a literal `\`, +/// `\xHH` decodes to the single byte `HH`, and anything else following a +/// backslash passes through literally (including the backslash itself, so a +/// stray trailing `\` at the end of the payload is preserved rather than +/// dropped). +fn decode(escaped: &[u8]) -> String { + let mut out = Vec::with_capacity(escaped.len()); + let mut i = 0; + while i < escaped.len() { + if escaped[i] == b'\\' && i + 1 < escaped.len() { + match escaped[i + 1] { + b'\\' => { + out.push(b'\\'); + i += 2; + } + b'x' if i + 3 < escaped.len() => { + match u8::from_str_radix( + std::str::from_utf8(&escaped[i + 2..i + 4]).unwrap_or(""), + 16, + ) { + Ok(byte) => { + out.push(byte); + i += 4; + } + Err(_) => { + out.push(escaped[i]); + i += 1; + } + } + } + _ => { + out.push(escaped[i]); + i += 1; + } + } + } else { + out.push(escaped[i]); + i += 1; + } + } + String::from_utf8_lossy(&out).into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cmds(bytes: &[u8]) -> Vec { + scan_split(bytes) + .marks + .into_iter() + .map(|(_, Osc633::CommandLine(s))| s) + .collect() + } + + #[test] + fn plain_command_line_bel_terminated() { + assert_eq!( + cmds(b"\x1b]633;E;gt crew at skippy\x07"), + vec!["gt crew at skippy"] + ); + } + + #[test] + fn st_terminated_and_embedded_in_output() { + assert_eq!(cmds(b"noise\x1b]633;E;ls -la\x1b\\more"), vec!["ls -la"]); + } + + #[test] + fn vscode_escapes_decode() { + // `;` is \x3b, backslash is \\, newline is \x0a + assert_eq!( + cmds(b"\x1b]633;E;echo a\\x3b b\\\\c\\x0ad\x07"), + vec!["echo a; b\\c\nd"] + ); + } + + #[test] + fn other_633_subcommands_ignored() { + // A/B/C/D/P exist in the VS Code protocol; only E matters here. + assert_eq!( + cmds(b"\x1b]633;A\x07\x1b]633;P;Cwd=/x\x07"), + Vec::::new() + ); + } + + #[test] + fn split_across_reads_reports_incomplete() { + let r = scan_split(b"out\x1b]633;E;gt cr"); + assert!(r.marks.is_empty()); + assert_eq!(r.incomplete, Some(3)); + } + + #[test] + fn oversized_sequence_resyncs() { + let mut b = b"\x1b]633;E;".to_vec(); + b.extend(std::iter::repeat_n(b'a', MAX_SEQ + 1)); + b.extend_from_slice(b"\x1b]633;E;ok\x07"); + assert_eq!(cmds(&b), vec!["ok"]); + } +} diff --git a/crates/ember-session/src/projection.rs b/crates/ember-session/src/projection.rs index ba49a01..789dc3b 100644 --- a/crates/ember-session/src/projection.rs +++ b/crates/ember-session/src/projection.rs @@ -87,6 +87,8 @@ pub struct AlacrittyProjection { scan_tail_133: Vec, /// Same, for OSC 1337. scan_tail_1337: Vec, + /// Same, for OSC 633. + scan_tail_633: Vec, /// Live scrollback-search state: the pattern as typed, its compiled DFA, /// and the engine-space range of the last match (the origin the next /// `search` call continues from). Reset when the pattern changes. @@ -121,6 +123,7 @@ struct Mark { enum Scanned { C133(Osc133), C1337(crate::osc1337::Osc1337), + C633(crate::osc633::Osc633), } impl AlacrittyProjection { @@ -140,22 +143,25 @@ impl AlacrittyProjection { resync: false, scan_tail_133: Vec::new(), scan_tail_1337: Vec::new(), + scan_tail_633: Vec::new(), } } /// Feed raw PTY bytes through the VT parser into the engine, and pre-scan them - /// for OSC 133 + OSC 1337 shell-integration sequences (alacritty ignores both, - /// so the bytes still flow through unchanged). Returns the semantic events - /// found, in order, and records prompt/exit/manual-mark state for the gutter. + /// for OSC 133 + OSC 1337 + OSC 633 shell-integration sequences (alacritty + /// ignores all three, so the bytes still flow through unchanged). Returns + /// the semantic events found, in order, and records prompt/exit/manual-mark + /// state for the gutter. pub fn advance(&mut self, bytes: &[u8]) -> Vec { let mut events = Vec::new(); // Scan over (carried tail ++ new bytes): a mark split across the 8 KB // read boundary is statistically inevitable in long sessions and used // to be lost forever. The tail's bytes were already fed to the engine - // last read — the carry exists only for the scanner. OSC 133 and OSC - // 1337 are scanned independently (each with its own carry), then - // merged back into buffer order below — carrying a wrong shared tail - // would either re-feed already-consumed bytes or duplicate a mark. + // last read — the carry exists only for the scanner. OSC 133, OSC + // 1337, and OSC 633 are scanned independently (each with its own + // carry), then merged back into buffer order below — carrying a wrong + // shared tail would either re-feed already-consumed bytes or + // duplicate a mark. let tail_len_133 = self.scan_tail_133.len(); let owned_133: Vec; let scan_buf_133: &[u8] = if tail_len_133 == 0 { @@ -176,15 +182,27 @@ impl AlacrittyProjection { owned_1337 = v; &owned_1337 }; + let tail_len_633 = self.scan_tail_633.len(); + let owned_633: Vec; + let scan_buf_633: &[u8] = if tail_len_633 == 0 { + bytes + } else { + let mut v = std::mem::take(&mut self.scan_tail_633); + v.extend_from_slice(bytes); + owned_633 = v; + &owned_633 + }; let result_133 = crate::osc133::scan_split(scan_buf_133); let result_1337 = crate::osc1337::scan_split(scan_buf_1337); + let result_633 = crate::osc633::scan_split(scan_buf_633); // Both scans' offsets are relative to their OWN scan buffer (which may // carry a different-length tail); rebase each to "offset within this // read's `bytes`" before merging, so the combined list is in true // buffer order regardless of which protocol's tail was longer. - let mut merged: Vec<(usize, Scanned)> = - Vec::with_capacity(result_133.marks.len() + result_1337.marks.len()); + let mut merged: Vec<(usize, Scanned)> = Vec::with_capacity( + result_133.marks.len() + result_1337.marks.len() + result_633.marks.len(), + ); merged.extend(result_133.marks.into_iter().map(|(past, m)| { ( past.saturating_sub(tail_len_133).min(bytes.len()), @@ -197,6 +215,12 @@ impl AlacrittyProjection { Scanned::C1337(m), ) })); + merged.extend(result_633.marks.into_iter().map(|(past, m)| { + ( + past.saturating_sub(tail_len_633).min(bytes.len()), + Scanned::C633(m), + ) + })); merged.sort_by_key(|(off, _)| *off); let mut cut = 0usize; @@ -250,6 +274,9 @@ impl AlacrittyProjection { } events.push(OscEvent::SetMark); } + Scanned::C633(crate::osc633::Osc633::CommandLine(cmd)) => { + events.push(OscEvent::CommandLine(cmd)); + } } } // Feed the remainder. @@ -264,6 +291,10 @@ impl AlacrittyProjection { if let Some(inc) = result_1337.incomplete { self.scan_tail_1337.extend_from_slice(&scan_buf_1337[inc..]); } + self.scan_tail_633.clear(); + if let Some(inc) = result_633.incomplete { + self.scan_tail_633.extend_from_slice(&scan_buf_633[inc..]); + } events } @@ -802,6 +833,29 @@ mod tests { ); } + #[test] + fn advance_emits_command_line_in_order() { + let mut p = proj(); + let events = p.advance(b"\x1b]133;B\x07\x1b]633;E;cargo test\x07\x1b]133;C\x07"); + assert!(matches!(events.as_slice(), + [OscEvent::CommandStart, OscEvent::CommandLine(c), OscEvent::OutputStart] + if c == "cargo test")); + } + + #[test] + fn command_line_split_across_advances() { + let mut p = proj(); + assert!( + p.advance(b"\x1b]633;E;gt crew ") + .iter() + .all(|e| !matches!(e, OscEvent::CommandLine(_))) + ); + let events = p.advance(b"at skippy\x07"); + assert!( + matches!(events.as_slice(), [OscEvent::CommandLine(c)] if c == "gt crew at skippy") + ); + } + #[test] fn request_full_reships_reset_plus_complete_style_table() { let mut p = proj(); diff --git a/crates/ember-session/src/shell_integration.rs b/crates/ember-session/src/shell_integration.rs index 6ca306f..c3ee12a 100644 --- a/crates/ember-session/src/shell_integration.rs +++ b/crates/ember-session/src/shell_integration.rs @@ -52,7 +52,17 @@ _ember_precmd() { print -n "\e]133;A\e\\" print -n "\e]1337;CurrentDir=$PWD\e\\" } -_ember_preexec() { print -n "\e]133;C\e\\" } +_ember_escape_cmd() { + local s=$1 + s=${s//$'\\'/\\\\} + s=${s//;/\\x3b} + s=${s//$'\n'/\\x0a} + print -rn -- "$s" +} +_ember_preexec() { + print -n "\e]633;E;$(_ember_escape_cmd "$1")\e\\" + print -n "\e]133;C\e\\" +} autoload -Uz add-zsh-hook 2>/dev/null if whence add-zsh-hook >/dev/null 2>&1; then add-zsh-hook precmd _ember_precmd @@ -117,16 +127,35 @@ fn prepare_zsh(dir: &Path) -> std::io::Result { const RCFILE_BASH_HEAD: &str = r#"[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc" _ember_precmd() { - local ret=$? + # `$?` here is the FOREGROUND command's exit status only because + # `_ember_status` was captured before the user's own PROMPT_COMMAND + # fragments (starship, direnv, etc.) ran and reset `$?` to their own — + # usually zero — result. The `:-$?` fallback is just a sane default for + # the (never expected) case this runs before that capture ever fires. + local ret=${_ember_status:-$?} printf '\e]133;D;%s\e\\' "$ret" printf '\e]133;A\e\\' printf '\e]1337;CurrentDir=%s\e\\' "$PWD" + _ember_interactive=on +} +_ember_escape_cmd() { + local s=$1 + s=${s//\\/\\\\} + s=${s//;/\\x3b} + s=${s//$'\n'/\\x0a} + printf '%s' "$s" } case "$PROMPT_COMMAND" in *_ember_precmd*) ;; - *) PROMPT_COMMAND="_ember_precmd${PROMPT_COMMAND:+; $PROMPT_COMMAND}" ;; + # Capture the foreground command's exit status FIRST, before any of the + # user's own PROMPT_COMMAND fragments run and overwrite `$?` with their + # own (usually zero) result — otherwise OSC 133;D would report bash's + # last hook status instead of the command the user actually ran. + # `_ember_precmd` stays LAST: the DEBUG-trap latch (`_ember_interactive`) + # depends on it running after everything else in PROMPT_COMMAND. + *) PROMPT_COMMAND="_ember_status=\$?; ${PROMPT_COMMAND:+$PROMPT_COMMAND; }_ember_precmd" ;; esac -trap 'printf "\e]133;C\e\\"' DEBUG +trap 'if [ "$_ember_interactive" = "on" ] && [ -z "$COMP_LINE" ]; then _ember_interactive=; _ember_hist_line=$(HISTTIMEFORMAT= history 1); if [ -n "$_ember_hist_line" ] && [ "$_ember_hist_line" != "$_ember_last_hist" ]; then _ember_last_hist=$_ember_hist_line; _ember_cmd=$(printf "%s\n" "$_ember_hist_line" | sed "s/^[[:space:]]*[0-9]*[[:space:]]*//"); printf "\e]633;E;%s\e\\" "$(_ember_escape_cmd "$_ember_cmd")"; fi; fi; printf "\e]133;C\e\\"' DEBUG "#; fn prepare_bash(dir: &Path) -> std::io::Result { @@ -227,6 +256,115 @@ mod tests { assert!(inj.env.is_empty() && inj.args.is_empty()); } + #[test] + fn zsh_hooks_emit_command_line() { + let dir = std::env::temp_dir().join(format!("ember-si-633z-{}", std::process::id())); + prepare("zsh", &dir); + let rc = std::fs::read_to_string(dir.join(".zshrc")).unwrap(); // match the actual file name used by prepare_zsh + assert!(rc.contains("633;E;")); + assert!(rc.contains("_ember_escape_cmd")); + } + + #[test] + fn bash_rcfile_emits_command_line() { + let dir = std::env::temp_dir().join(format!("ember-si-633b-{}", std::process::id())); + prepare("bash", &dir); + let rc = std::fs::read_to_string(dir.join("ember-bash-rc")).unwrap(); + assert!(rc.contains("633;E;")); + assert!(rc.contains("_ember_hist_line")); // History dedup mechanism + } + + /// Regression: the foreground command's exit status must be captured + /// BEFORE any of the user's own `PROMPT_COMMAND` fragments (starship, + /// direnv, etc.) run — otherwise those fragments (usually exit-0 + /// themselves) clobber `$?` before `_ember_precmd` ever reads it, and + /// OSC 133;D reports the wrong status. `_ember_precmd` must still be + /// LAST, since the DEBUG-trap latch depends on it running after + /// everything else in `PROMPT_COMMAND`. + #[test] + fn bash_rcfile_captures_exit_status_before_user_prompt_command() { + let dir = std::env::temp_dir().join(format!("ember-si-bash-status-{}", std::process::id())); + prepare("bash", &dir); + let rc = std::fs::read_to_string(dir.join("ember-bash-rc")).unwrap(); + + assert!( + rc.contains("local ret=${_ember_status:-$?}"), + "_ember_precmd must read the captured status, not raw $?: {rc:?}" + ); + + let assign_at = rc + .find("PROMPT_COMMAND=\"_ember_status=\\$?;") + .expect("status capture must open the new PROMPT_COMMAND assignment"); + let user_frag_at = rc + .find("${PROMPT_COMMAND:+$PROMPT_COMMAND; }") + .expect("must still chain the user's existing PROMPT_COMMAND"); + let precmd_call_at = rc + .rfind("_ember_precmd\"") + .expect("_ember_precmd must be appended to the assignment"); + assert!( + assign_at < user_frag_at, + "status capture must precede the user's PROMPT_COMMAND fragment: {rc:?}" + ); + assert!( + user_frag_at < precmd_call_at, + "_ember_precmd must run after the user's fragment (DEBUG-trap latch depends on it being last): {rc:?}" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn zsh_smoke_test_hook_setup() { + if !std::path::Path::new("/bin/zsh").exists() { + return; // no zsh on this runner — skip + } + let dir = std::env::temp_dir().join(format!("ember-si-smoke-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let inj = prepare("zsh", &dir); + + // Verify that the hooks are correctly installed in the generated rc files + let rc = std::fs::read_to_string(dir.join(".zshrc")).unwrap(); + assert!(rc.contains("633;E;"), "OSC 633;E not in .zshrc"); + assert!( + rc.contains("_ember_escape_cmd"), + "Escape function not in .zshrc" + ); + assert!(rc.contains("_ember_preexec"), "Preexec hook not in .zshrc"); + + // Verify that the escape function has the correct escaping logic + assert!( + rc.contains(r#"s=${s//$'\\'/\\\\}"#), + "Backslash escaping not found" + ); + assert!( + rc.contains(r#"s=${s//;/\\x3b}"#), + "Semicolon escaping not found" + ); + assert!( + rc.contains(r#"s=${s//$'\n'/\\x0a}"#), + "Newline escaping not found" + ); + + // Run zsh to verify the hooks are syntactically valid and load + let mut cmd = std::process::Command::new("/bin/zsh"); + cmd.args([ + "-ic", + "whence _ember_escape_cmd >/dev/null && echo HOOKS_OK", + ]); + for (k, v) in &inj.env { + cmd.env(k, v); + } + + let output = cmd.output().unwrap(); + let stdout_str = String::from_utf8_lossy(&output.stdout); + assert!( + stdout_str.contains("HOOKS_OK"), + "Hooks failed to load in zsh: {}", + stdout_str + ); + + let _ = std::fs::remove_dir_all(&dir); + } + /// Drive a REAL zsh through the injection and prove (a) ember's hooks /// install, (b) the user's own .zshrc still runs, (c) ZDOTDIR is restored — /// i.e. the "zsh re-evaluates ZDOTDIR per startup file" trap is handled. @@ -306,4 +444,129 @@ mod tests { "ember hooks not installed: {out:?}" ); } + + #[test] + fn bash_debug_trap_latch_guards_against_prompt_command() { + if !std::path::Path::new("/bin/bash").exists() { + return; // no bash on this runner — skip + } + let dir = std::env::temp_dir().join(format!("ember-si-bash-latch-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let _ = prepare("bash", &dir); + + // Get the path to the ember-bash-rc that was created + let rcfile = dir.join("ember-bash-rc"); + let output_file = dir.join("output.txt"); + + // Run bash interactively with stdin, redirecting output to a file + // The user's PROMPT_COMMAND has a marker that we can detect in the output + // Use /dev/null for HISTFILE to avoid polluting real history + let mut cmd = std::process::Command::new("/bin/bash"); + cmd.args(["--rcfile", rcfile.to_string_lossy().as_ref(), "-i"]); + cmd.env("PROMPT_COMMAND", "echo USER-PROMPT-MARKER"); + cmd.env("HISTFILE", "/dev/null"); + cmd.stdin(std::process::Stdio::piped()); + // Redirect stdout to file to capture it + let file = std::fs::File::create(&output_file).unwrap(); + cmd.stdout(file); + + let mut child = cmd.spawn().unwrap(); + { + let stdin = child.stdin.as_mut().unwrap(); + use std::io::Write; + // Coordinator's repro: two bare Enters, then a typed command + stdin.write_all(b"\n\necho TESTCMD\nexit\n").unwrap(); + } + + let _ = child.wait().unwrap(); + + // Read the captured output + let stdout_str = std::fs::read_to_string(&output_file).unwrap_or_default(); + eprintln!("Bash latch test output:\n{}", stdout_str); + + // Count how many times each pattern appears + let testcmd_count = stdout_str.matches("633;E;echo TESTCMD").count(); + let marker_count = stdout_str.matches("633;E;echo USER-PROMPT-MARKER").count(); + let precmd_count = stdout_str.matches("633;E;_ember_precmd").count(); + + eprintln!("633;E;echo TESTCMD count: {}", testcmd_count); + eprintln!("633;E;USER-PROMPT-MARKER count: {}", marker_count); + eprintln!("633;E;_ember_precmd count: {}", precmd_count); + + // Verify that 633;E is emitted exactly once for the user's typed command + assert_eq!( + testcmd_count, 1, + "Expected exactly one 633;E for 'echo TESTCMD', got {}: {:?}", + testcmd_count, stdout_str + ); + + // Verify that 633;E is NOT emitted for the user's PROMPT_COMMAND + // (history dedup prevents stale latch from firing during bare Enter cycles) + assert_eq!( + marker_count, 0, + "Spurious 633;E emitted for user's PROMPT_COMMAND {} times: {:?}", + marker_count, stdout_str + ); + + // Verify that 633;E is NOT emitted for _ember_precmd + assert_eq!( + precmd_count, 0, + "Spurious 633;E emitted for _ember_precmd {} times: {:?}", + precmd_count, stdout_str + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// Drives a REAL bash through the injection with a user `PROMPT_COMMAND` + /// set (the exact condition that broke exit-status reporting: `starship`, + /// `direnv`, and similar hooks install their own `PROMPT_COMMAND` + /// fragment, which previously ran BEFORE `_ember_precmd` and clobbered + /// `$?` with their own — usually zero — result). Runs a command that + /// fails (`false`, exit 1) and confirms OSC 133;D still reports the + /// FOREGROUND command's real exit status, not the user fragment's. + #[test] + fn bash_reports_real_exit_status_with_a_user_prompt_command_set() { + if !std::path::Path::new("/bin/bash").exists() { + return; // no bash on this runner — skip + } + let dir = std::env::temp_dir().join(format!("ember-si-bash-exit-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let _ = prepare("bash", &dir); + + let rcfile = dir.join("ember-bash-rc"); + let output_file = dir.join("output.txt"); + + let mut cmd = std::process::Command::new("/bin/bash"); + cmd.args(["--rcfile", rcfile.to_string_lossy().as_ref(), "-i"]); + // A user PROMPT_COMMAND fragment that itself exits 0 — like + // starship/direnv's hooks typically do. Before the fix, this ran + // BEFORE `_ember_precmd` and silently reset `$?` to 0. + cmd.env("PROMPT_COMMAND", "true"); + cmd.env("HISTFILE", "/dev/null"); + cmd.stdin(std::process::Stdio::piped()); + let file = std::fs::File::create(&output_file).unwrap(); + cmd.stdout(file); + + let mut child = cmd.spawn().unwrap(); + { + let stdin = child.stdin.as_mut().unwrap(); + use std::io::Write; + stdin.write_all(b"false\nexit\n").unwrap(); + } + let _ = child.wait().unwrap(); + + let stdout_str = std::fs::read_to_string(&output_file).unwrap_or_default(); + eprintln!("Bash exit-status test output:\n{}", stdout_str); + + assert!( + stdout_str.contains("133;D;1"), + "expected 133;D;1 (the real exit status of `false`) with a user \ + PROMPT_COMMAND set — got 0 or missing, meaning the user's \ + fragment clobbered $? before _ember_precmd read it: {:?}", + stdout_str + ); + + let _ = std::fs::remove_dir_all(&dir); + } }