diff --git a/Sources/termio/App/App.swift b/Sources/termio/App/App.swift index c5b4485d..90d737d4 100644 --- a/Sources/termio/App/App.swift +++ b/Sources/termio/App/App.swift @@ -611,6 +611,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: work) } + func windowDidDeminiaturize(_ notification: Notification) { + // Restoring from the Dock can bring the window back under its content minimum, and leaves a + // tickless terminal surface (and a mid-load WKWebView in the Issues pane) unpainted — the + // "minimize while a GitHub issue loads → tiny + black" report. Clamp a shrunken frame back + // up (setFrame ignores contentMinSize), force a relayout, and nudge the surface to repaint. + guard let window else { return } + let minFrameHeight = window.frameRect(forContentRect: + NSRect(origin: .zero, size: window.contentMinSize)).height + if window.frame.height < minFrameHeight { + var frame = window.frame + frame.origin.y -= (minFrameHeight - frame.height) // grow from the top, keep it fixed + frame.size.height = minFrameHeight + window.setFrame(frame, display: true) + } + window.contentView?.layoutSubtreeIfNeeded() + store.repaintSelectedSurface() + } + /// Lets the right inspector grow with the window: its max width is the golden ratio (0.618) /// of the current content width, floored at 420pt so it never shrinks below the old fixed cap /// on narrow windows. A static `maximumThickness` capped the inspector at 420pt regardless of @@ -998,6 +1016,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } else { maximizedDetailHost?.removeFromSuperview() maximizedDetailHost = nil + // Removing the host re-exposes the terminal, but a tickless surface won't repaint + // itself — nudge it so a switch out of a maximized detail can't leave a blank. + store.repaintSelectedSurface() } } diff --git a/Sources/termio/Git/GitModels.swift b/Sources/termio/Git/GitModels.swift index 601d6ea5..5ed58ed7 100644 --- a/Sources/termio/Git/GitModels.swift +++ b/Sources/termio/Git/GitModels.swift @@ -6,7 +6,7 @@ import SwiftUI /// Which inspector pane the trailing column is showing — the file tree, the file /// search, the git changes list, the issue tracker, or the session Info pane. /// Drives the segmented switch at the top of `FileBrowserView`. -enum InspectorTab: Hashable, Sendable { +enum InspectorTab: String, Hashable, Sendable, Codable { case files, search, changes, issues, info } diff --git a/Sources/termio/Issues/IssuesView.swift b/Sources/termio/Issues/IssuesView.swift index 8e0065aa..7dbb293f 100644 --- a/Sources/termio/Issues/IssuesView.swift +++ b/Sources/termio/Issues/IssuesView.swift @@ -33,7 +33,7 @@ struct IssuesView: View { // editor and diff), driven by `store.openIssueDetail`, not pushed in here. listPane .task(id: repoRoot) { - store.issuesModel = model + store.registerIssuesModel(model) await model.start() } // Selection IS the open gesture; route it to the center overlay. Follow the @@ -624,6 +624,11 @@ struct IssueDetailView: View { ), background: settings.terminalBackgroundColor ) + // Fill like the error/progress branches below: without this, SwiftUI can size the + // representable from the WKWebView's intrinsic (near-zero while it's mid-load), which + // collapses the detail to a sliver — and the empty area paints the window background + // (reads as a black window, most visibly across a minimize/restore relayout). + .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let error = model.detailError { ContentUnavailableView("Couldn’t Load", huge: .github, description: Text(error)) .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/Sources/termio/Sidebar/SidebarView.swift b/Sources/termio/Sidebar/SidebarView.swift index d4376de1..ded88580 100644 --- a/Sources/termio/Sidebar/SidebarView.swift +++ b/Sources/termio/Sidebar/SidebarView.swift @@ -1119,9 +1119,13 @@ private struct SessionRow: View { // click-to-select doesn't commit for seconds (the drag machinery swallows // the mouseDown), which shipped as v0.19.0's dead sidebar clicks. .simultaneousGesture(TapGesture().onEnded { - // Tapping a session always returns to its terminal — close the file - // editor even when this row is already selected (no change to react to). - store.openFileURL = nil + // Re-tapping the row you're already on returns to its terminal, closing its + // open file editor. Tapping a *different* session must NOT clear here: the + // selection didSet captures the outgoing session's inspector layout first + // (issue #160), so clearing pre-capture would drop its open file. + if store.selectedSessionID == session.id { + store.openFileURL = nil + } store.selectedSessionID = session.id // Re-tapping the row you're already on still clears a resting // done/attention dot (the selection didSet only reacts to a change). diff --git a/Sources/termio/Terminal/TerminalPane.swift b/Sources/termio/Terminal/TerminalPane.swift index 783d8ab4..487519c4 100644 --- a/Sources/termio/Terminal/TerminalPane.swift +++ b/Sources/termio/Terminal/TerminalPane.swift @@ -106,13 +106,10 @@ struct TerminalPane: View { if let id, !activated.contains(id) { activated.append(id) } - // Switching sessions returns to the terminal: dismiss any open file editor so the - // newly selected session's surface is what's shown (the overlay's `.onDisappear` - // flushes any pending auto-save first). The diff overlay is dismissed for the same reason. - store.openFileURL = nil - store.openDiff = nil - store.openTrace = nil - store.openIssueDetail = nil + // The inspector layout now follows the session: `TermioStore.selectedSessionID` + // saves the outgoing session's open detail and restores the incoming one's + // (issue #160), so we no longer clear the overlays here. The editor overlay's + // `.onDisappear` still flushes any pending auto-save when a restore replaces it. requestSelectedTerminalFocus(reason: .selectionChanged) } // The toolbar's close button posts this; tear the overlay down the same way the overlay's diff --git a/Sources/termio/TermioStore/StateFile.swift b/Sources/termio/TermioStore/StateFile.swift index 28d37e1a..8409e812 100644 --- a/Sources/termio/TermioStore/StateFile.swift +++ b/Sources/termio/TermioStore/StateFile.swift @@ -15,6 +15,21 @@ struct StateFile { /// The split groups (see `TermioStore.splitGroups`). Optional so state /// files written before groups existed still decode. var splitGroups: [SplitNode]? + /// Each session's inspector layout, keyed by session `id.uuidString`. Only the + /// durable subset is written — the tab and the open *file* — since a diff / PR / + /// trace is a snapshot of data that gets re-fetched (see `TermioStore.InspectorState`). + /// Optional so older state files still decode. + var inspectorLayouts: [String: InspectorLayout]? + } + + /// The persisted slice of a session's inspector layout: which tab, and the file it + /// had open (validated for existence on restore, since the file may have been deleted + /// or its worktree removed while the app was closed). + struct InspectorLayout: Codable { + var tab: InspectorTab + var filePath: String? + var fileLine: Int? + var fileReadOnly: Bool? } let url = AppChannel.supportDirectory diff --git a/Sources/termio/TermioStore/TermioStore+TerminalSurface.swift b/Sources/termio/TermioStore/TermioStore+TerminalSurface.swift index dc11b44a..c9e32c07 100644 --- a/Sources/termio/TermioStore/TermioStore+TerminalSurface.swift +++ b/Sources/termio/TermioStore/TermioStore+TerminalSurface.swift @@ -844,6 +844,16 @@ extension TermioStore { /// than waiting for the next PTY-output wakeup. A fixed short pump is enough: a /// color/font reconfigure needs no reply-gated handshake the way a cold spawn /// does, so a handful of frames flush the new look. + /// Repaints the selected session's surface for a brief window. Used when a full-window + /// overlay — the maximized inspector detail — is torn down and re-exposes the terminal: + /// this embedding has no continuous tick (see `warmUpRendering`), so an uncovered surface + /// would otherwise sit on its stale last frame (a blank on a fresh session) until the next + /// PTY-output or focus event. This is issue #160's fullscreen blank-on-tab-switch. + func repaintSelectedSurface() { + guard let id = selectedSessionID, let state = surfaces[id] else { return } + pumpRendering(state, duration: 0.25) + } + private func pumpRendering(_ state: TerminalViewState, duration: TimeInterval) { let started = Date() let timer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { [weak state] timer in diff --git a/Sources/termio/TermioStore/TermioStore.swift b/Sources/termio/TermioStore/TermioStore.swift index ea82276c..37f54be7 100644 --- a/Sources/termio/TermioStore/TermioStore.swift +++ b/Sources/termio/TermioStore/TermioStore.swift @@ -24,6 +24,18 @@ final class TermioStore: ObservableObject { // "needs attention" (or unseen "done") is, by definition, answered. didSet { guard oldValue != selectedSessionID else { return } + // Save the inspector layout of the session we're leaving and restore the one + // we're arriving at, so each terminal tab keeps its own right-side context + // (issue #160). This replaces the blanket overlay-clear that used to live in + // `TerminalPane` — and, because we no longer tear the maximize host down to + // nothing on every switch, it also removes the fullscreen blank-screen race. + // Suppressed during launch restore: `restored()` seeds every session's layout + // and applies the selected one by hand, so capturing here would overwrite a + // just-seeded layout with the still-default live inspector. + if !isRestoringInspector { + if let old = oldValue { inspectorStates[old] = captureInspectorState() } + applyInspectorState(selectedSessionID.flatMap { inspectorStates[$0] } ?? InspectorState()) + } if let id = selectedSessionID { // A mid-turn `.working` keeps its spinner; only the resting // "your turn" states are answered by looking. @@ -185,14 +197,32 @@ final class TermioStore: ObservableObject { if inspectorMaximized { inspectorMaximized = false } if inspectorListCollapsed { inspectorListCollapsed = false } } + // Every detail change (and, via the tab's own clears, every tab switch) funnels + // through here, so it's the one place to schedule the durable-layout save. + persistInspectorSoon() } - /// The Issues pane's model, held here (in addition to the inspector view that owns it) - /// so an open PR/issue detail in the center keeps its data — conversation, PR files, - /// checkout — even when the inspector switches to another tab or collapses and its view - /// is torn down. `IssuesView` points this at its model on appear; `TerminalPane`'s - /// detail overlay reads it. - @Published var issuesModel: IssuesPanelModel? + /// The Issues pane's models, cached by repo root, held here (beyond the inspector view + /// that owns each) so an open PR/issue detail keeps its data — conversation, PR files, + /// checkout — even when the inspector switches tab / collapses and `IssuesView` is torn + /// down. `IssuesView` registers its model on appear; the detail overlay reads + /// `issuesModel`, which resolves to the *selected session's* repo. That pairing is what + /// makes per-session issue restore (issue #160) safe: returning to a session can never + /// render its saved issue against another repo's model. + @Published private(set) var issuesModels: [String: IssuesPanelModel] = [:] + + /// Registers (or refreshes) the Issues model for its repo root. Called by `IssuesView`. + func registerIssuesModel(_ model: IssuesPanelModel) { + issuesModels[model.repoRoot] = model + } + + /// The Issues model for the currently selected session's repo, or `nil` when none has + /// loaded yet — the detail overlay then falls back to the list rather than fetching an + /// issue against the wrong repo. Keyed on `inspectorProjectPath`, the exact string + /// `IssuesView` is created with (see `FileBrowserView.projectPath`). + var issuesModel: IssuesPanelModel? { + inspectorProjectPath.flatMap { issuesModels[$0] } + } /// Which pane the trailing inspector shows — the file tree or git changes. Set by the toolbar's /// segmented switch and read by `FileBrowserView`. (The inspector's open/closed state is owned by @@ -236,6 +266,84 @@ final class TermioStore: ObservableObject { /// spawning `git status` for a pane nobody could see. @Published var inspectorVisible = false + /// A per-session snapshot of the inspector's *content* — which tab is showing and + /// which detail (file / diff / trace / PR-issue) is open, plus the detail's + /// maximize / list-collapse chrome. Switching terminal tabs restores each session's + /// own right-side context instead of clearing it (issue #160): one session left on a + /// file, another on a PR, another on the changes list. The inspector's *width* and + /// *open/closed* state stay global — those belong to the AppKit split item; it's the + /// content that is session-specific. + struct InspectorState { + var tab: InspectorTab = .files + var openFileURL: URL? + var openFileLine: Int? + var openFileReadOnly = false + var openDiff: GitDiffRequest? + var openTrace: TraceRequest? + var openIssueDetail: IssueSummary? + var maximized = false + var listCollapsed = false + } + + /// Each session's saved inspector layout, written when the selection leaves a + /// session and read back when it returns (see `selectedSessionID`'s didSet). Seeded + /// from `state.json` on launch for the tab + open-file subset; the live diff / trace + /// / PR details are in-memory only — they're snapshots of data that gets re-fetched, + /// so they don't survive a quit (matching VS Code's hot exit, which restores open + /// files but not transient views). Keyed by session, so a dead session's entry is + /// pruned alongside its runtime in `syncRuntimes`. + var inspectorStates: [Session.ID: InspectorState] = [:] + + /// True only while `restored()` seeds the saved layouts and hand-applies the selected + /// one — it suppresses the capture/restore in `selectedSessionID`'s didSet so a + /// programmatic selection during launch can't overwrite a just-seeded layout. + private var isRestoringInspector = false + + /// Debounced whole-state save for durable inspector edits (opening a file, switching + /// the tab). Unlike a session switch, these don't move `selectedSessionID`, so nothing + /// else persists them — without this, opening a file and quitting without switching + /// would lose it. Debounced so a burst of clicks writes once. Skipped during restore. + private func persistInspectorSoon() { + guard !isRestoringInspector else { return } + persistDebounce?.cancel() + let work = DispatchWorkItem { [weak self] in MainActor.assumeIsolated { self?.persist() } } + persistDebounce = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4, execute: work) + } + + /// Snapshots the inspector's current content into an `InspectorState`. + private func captureInspectorState() -> InspectorState { + InspectorState( + tab: inspectorTab, + openFileURL: openFileURL, + openFileLine: openFileLine, + openFileReadOnly: openFileReadOnly, + openDiff: openDiff, + openTrace: openTrace, + openIssueDetail: openIssueDetail, + maximized: inspectorMaximized, + listCollapsed: inspectorListCollapsed + ) + } + + /// Restores a session's saved inspector layout (or the default when it has none). + /// Order is load-bearing: `inspectorTab`'s didSet clears the details, so the tab is + /// set first; the issue is set before the diff because a PR file diff deliberately + /// stacks on top of an open issue (see `openIssueDetail`); and the read-only flag / + /// jump line precede the file URL (see `openFileURL`). + private func applyInspectorState(_ state: InspectorState) { + inspectorTab = state.tab + openFileURL = nil; openDiff = nil; openTrace = nil; openIssueDetail = nil + openTrace = state.openTrace + openIssueDetail = state.openIssueDetail + openFileReadOnly = state.openFileReadOnly + openFileLine = state.openFileLine + openFileURL = state.openFileURL + openDiff = state.openDiff + inspectorMaximized = state.maximized + inspectorListCollapsed = state.listCollapsed + } + /// Per-session high-frequency live state (status, running tool, live title, cwd), /// each held in its own `@Observable` `SessionRuntime` so a change re-renders only /// the owning sidebar row rather than the whole tree. Deliberately **not** @@ -282,6 +390,8 @@ final class TermioStore: ObservableObject { let live = Set(projects.flatMap(\.sessions).map(\.id)) for id in live where runtimes[id] == nil { runtimes[id] = SessionRuntime() } for id in runtimes.keys where !live.contains(id) { runtimes.removeValue(forKey: id) } + // A closed session's saved inspector layout goes with it. + for id in inspectorStates.keys where !live.contains(id) { inspectorStates.removeValue(forKey: id) } } /// Sets a session's status, no-op-guarded so a redundant same-value write (the hook @@ -781,9 +891,35 @@ final class TermioStore: ObservableObject { projects: migratingScratchProject(migratingHomeProject(normalizingAgentTitles(snapshot.projects))), settings: settings ) + // Seed each session's saved inspector layout (tab + open file). The file is + // validated for existence — a file deleted, or a worktree removed, while the app + // was closed silently falls back to no detail rather than an error overlay. + if let layouts = snapshot.inspectorLayouts { + let live = Set(store.projects.flatMap(\.sessions).map(\.id)) + for (key, layout) in layouts { + guard let id = UUID(uuidString: key), live.contains(id) else { continue } + var state = InspectorState(tab: layout.tab) + if let path = layout.filePath, FileManager.default.fileExists(atPath: path) { + state.openFileURL = URL(fileURLWithPath: path) + state.openFileLine = layout.fileLine + state.openFileReadOnly = layout.fileReadOnly ?? false + } + store.inspectorStates[id] = state + } + } + // Guard the selection change so its didSet neither captures the (still-default) + // live inspector over a just-seeded layout nor schedules a startup save. + store.isRestoringInspector = true if let id = snapshot.selectedSessionID, store.session(id) != nil { store.selectedSessionID = id } + // The designated init set the selection without firing its didSet, and re-setting + // it to the same id above is a no-op, so apply the selected session's restored + // layout to the live inspector props explicitly. + if let id = store.selectedSessionID, let state = store.inspectorStates[id] { + store.applyInspectorState(state) + } + store.isRestoringInspector = false // Restore the split groups, keeping only those whose panes all still // resolve to live sessions (a stale group is dropped whole rather than // patched — the user just re-splits). State files from before groups @@ -868,10 +1004,26 @@ final class TermioStore: ObservableObject { } private func persist() { + // Fold the current selection's live inspector layout in — it isn't copied into + // `inspectorStates` until the selection leaves it. + var states = inspectorStates + if let id = selectedSessionID { states[id] = captureInspectorState() } + var layouts: [String: StateFile.InspectorLayout] = [:] + for (id, state) in states { + // Skip the plain default (Files tab, nothing open) to keep the file lean. + guard state.tab != .files || state.openFileURL != nil else { continue } + layouts[id.uuidString] = StateFile.InspectorLayout( + tab: state.tab, + filePath: state.openFileURL?.path, + fileLine: state.openFileLine, + fileReadOnly: state.openFileReadOnly + ) + } stateFile.save(.init( projects: projects, selectedSessionID: selectedSessionID, - splitGroups: splitGroups + splitGroups: splitGroups, + inspectorLayouts: layouts.isEmpty ? nil : layouts )) }