From dc4753f2b3aa257956878eb207c2cfcd62304c17 Mon Sep 17 00:00:00 2001 From: "Jiwei,Yuan" Date: Wed, 29 Jul 2026 12:12:22 +0100 Subject: [PATCH 1/4] feat(inspector): make the right-side layout follow the session (#160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inspector tab and open detail (file / diff / trace / PR-issue) plus its maximize / list-collapse chrome were global singletons on TermioStore, force- cleared on every session switch. Each terminal tab now keeps its own right-side context in an in-memory `[Session.ID: InspectorState]`: the outgoing session's layout is saved and the incoming one restored, replacing the blanket overlay clear in TerminalPane. Because the maximize host is no longer torn down to nothing on every switch, the fullscreen blank-screen race goes away too. Persistence rides state.json (no SQLite): only the durable subset — the tab and the open file (path / line / read-only) — is written, since a diff / PR / trace is a snapshot of data that gets re-fetched. On restore the file path is validated and silently falls back to no detail if it's gone. Width and open/ closed stay global (they belong to the AppKit split item); it's the content that is session-specific — matching VS Code's sidebar-viewlet ⊥ editor-area model. Co-Authored-By: Claude Opus 4.8 --- Sources/termio/Git/GitModels.swift | 2 +- Sources/termio/Terminal/TerminalPane.swift | 11 +- Sources/termio/TermioStore/StateFile.swift | 15 +++ Sources/termio/TermioStore/TermioStore.swift | 110 ++++++++++++++++++- 4 files changed, 129 insertions(+), 9 deletions(-) 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/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.swift b/Sources/termio/TermioStore/TermioStore.swift index ea82276c..e43906db 100644 --- a/Sources/termio/TermioStore/TermioStore.swift +++ b/Sources/termio/TermioStore/TermioStore.swift @@ -24,6 +24,13 @@ 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. + 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. @@ -236,6 +243,67 @@ 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] = [:] + + /// 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 +350,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 +851,31 @@ 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 + } + } 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) + } // 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 +960,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 )) } From 85fa978b8338e54ef1f1fc7cb7101aed28a0d4d2 Mon Sep 17 00:00:00 2001 From: "Jiwei,Yuan" Date: Wed, 29 Jul 2026 12:23:59 +0100 Subject: [PATCH 2/4] =?UTF-8?q?fix(inspector):=20address=20codex=20review?= =?UTF-8?q?=20=E2=80=94=20capture,=20restore,=20and=20quit-persist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three regressions the per-session inspector work introduced (PR #161 review): 1. Sidebar tab clicks cleared `openFileURL` before changing the selection, so the didSet captured an already-emptied layout and the outgoing session's open file was lost on switch-away. Only clear when re-tapping the already-selected row. 2. Durable inspector edits (open a file, switch the tab) don't move `selectedSessionID`, so nothing persisted them — opening a file and quitting without switching lost it. Added a debounced whole-state save funnelled through `refreshDetailPresentation`. 3. Launch restore clobbered a session's seeded layout: the programmatic selection change fired the didSet, which captured the still-default live inspector over the just-seeded state. Guard restore with `isRestoringInspector`. Co-Authored-By: Claude Opus 4.8 --- Sources/termio/Sidebar/SidebarView.swift | 10 ++++-- Sources/termio/TermioStore/TermioStore.swift | 33 ++++++++++++++++++-- 2 files changed, 38 insertions(+), 5 deletions(-) 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/TermioStore/TermioStore.swift b/Sources/termio/TermioStore/TermioStore.swift index e43906db..1746740c 100644 --- a/Sources/termio/TermioStore/TermioStore.swift +++ b/Sources/termio/TermioStore/TermioStore.swift @@ -29,8 +29,13 @@ final class TermioStore: ObservableObject { // (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. - if let old = oldValue { inspectorStates[old] = captureInspectorState() } - applyInspectorState(selectedSessionID.flatMap { inspectorStates[$0] } ?? InspectorState()) + // 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. @@ -192,6 +197,9 @@ 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) @@ -271,6 +279,23 @@ final class TermioStore: ObservableObject { /// 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( @@ -867,6 +892,9 @@ final class TermioStore: ObservableObject { 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 } @@ -876,6 +904,7 @@ final class TermioStore: ObservableObject { 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 From df427ff9963967045446c5c6c637dd00ef18a0d8 Mon Sep 17 00:00:00 2001 From: "Jiwei,Yuan" Date: Wed, 29 Jul 2026 12:49:46 +0100 Subject: [PATCH 3/4] fix(inspector): per-repo issues model + repaint on maximize teardown Addresses the two deeper findings from the PR #161 review: 4. `issuesModel` was a single global slot, so restoring a session's saved issue detail could render it against another repo's model (wrong-repo fetch). It's now a `[repoRoot: IssuesPanelModel]` cache; `issuesModel` resolves to the selected session's repo (keyed on `inspectorProjectPath`, the exact string `IssuesView` is built with), falling back to the list when none has loaded. 5. The fullscreen blank-on-tab-switch is the tickless surface sitting on a stale frame after the maximized-detail host is removed. Nudge the selected surface with a short render pump when the host tears down (`repaintSelectedSurface`). Co-Authored-By: Claude Opus 4.8 --- Sources/termio/App/App.swift | 3 +++ Sources/termio/Issues/IssuesView.swift | 2 +- .../TermioStore+TerminalSurface.swift | 10 +++++++ Sources/termio/TermioStore/TermioStore.swift | 27 ++++++++++++++----- 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/Sources/termio/App/App.swift b/Sources/termio/App/App.swift index c5b4485d..a71d2bce 100644 --- a/Sources/termio/App/App.swift +++ b/Sources/termio/App/App.swift @@ -998,6 +998,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/Issues/IssuesView.swift b/Sources/termio/Issues/IssuesView.swift index 8e0065aa..1cc710af 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 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 1746740c..37f54be7 100644 --- a/Sources/termio/TermioStore/TermioStore.swift +++ b/Sources/termio/TermioStore/TermioStore.swift @@ -202,12 +202,27 @@ final class TermioStore: ObservableObject { 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 From 94e5e2c5743f25de62bbb84f0a4248e1a038e1ae Mon Sep 17 00:00:00 2001 From: "Jiwei,Yuan" Date: Wed, 29 Jul 2026 13:53:39 +0100 Subject: [PATCH 4/4] fix(issues): stop minimize-while-loading from collapsing to a tiny black window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The issue detail's WKWebView was the one branch of `conversationBody` without a fill frame (its error/progress siblings both have `maxWidth/maxHeight: .infinity`). Without it, SwiftUI could size the representable from the web view's intrinsic — near-zero while the HTML is mid-load — collapsing the detail to a sliver, and the empty area paints the window background as black. Most visible across a minimize/restore relayout. Also harden the restore path: `windowDidDeminiaturize` clamps a frame that came back under the content minimum (setFrame ignores contentMinSize), forces a relayout, and nudges the tickless terminal surface to repaint — same surface- won't-repaint-itself family as the maximize-teardown fix. Co-Authored-By: Claude Opus 4.8 --- Sources/termio/App/App.swift | 18 ++++++++++++++++++ Sources/termio/Issues/IssuesView.swift | 5 +++++ 2 files changed, 23 insertions(+) diff --git a/Sources/termio/App/App.swift b/Sources/termio/App/App.swift index a71d2bce..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 diff --git a/Sources/termio/Issues/IssuesView.swift b/Sources/termio/Issues/IssuesView.swift index 1cc710af..7dbb293f 100644 --- a/Sources/termio/Issues/IssuesView.swift +++ b/Sources/termio/Issues/IssuesView.swift @@ -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)