From 0ff00a1d1b06d4b399f3d9b3d5b32c74fb130582 Mon Sep 17 00:00:00 2001 From: arzafran Date: Mon, 14 Sep 2026 01:09:31 +0200 Subject: [PATCH 1/2] fix: preserve sessions and restore native window interactions --- .github/workflows/ci.yml | 3 + .github/workflows/test-e2e.yml | 1 + CHANGELOG.md | 6 + GhosttyTabs.xcodeproj/project.pbxproj | 4 + Resources/Localizable.xcstrings | 63 +++- Sources/AppDelegate+StartupHandoff.swift | 75 ++++ Sources/AppDelegate.swift | 306 +++++++++++---- Sources/ContentView.swift | 31 +- Sources/ProgramaApp.swift | 1 + Sources/RendererRealization.swift | 3 +- Sources/SessionAutosaveCoordinator.swift | 75 ++-- Sources/SessionEscrow.swift | 62 ++- Sources/SessionPersistence.swift | 230 +++++++++++- Sources/SessionWALStore.swift | 27 +- Sources/SidebarQuotaFooter.swift | 64 ++-- Sources/TabItemView.swift | 4 +- Sources/TabManager+SessionPersistence.swift | 43 ++- Sources/TabManager.swift | 37 +- Sources/TerminalController+Worktree.swift | 7 +- Sources/WindowDragHandleView.swift | 12 +- Sources/WindowPaneChromePortal.swift | 21 +- Sources/WindowSwizzles.swift | 11 +- Sources/Workspace+Persistence.swift | 11 +- docs/keyboard-shortcuts.md | 7 + docs/testing-layout.md | 19 +- .../AppDelegateShortcutRoutingTests.swift | 353 +++++++++++++----- .../RendererRealizationPlannerTests.swift | 17 +- .../SessionAutosaveCoordinatorTests.swift | 120 +++++- programaTests/SessionPersistenceTests.swift | 174 ++++++++- .../TabManagerSessionSnapshotTests.swift | 126 +++++++ programaTests/WindowAndDragTests.swift | 59 ++- programaUITests/BonsplitTabDragUITests.swift | 36 ++ .../CloseWindowConfirmDialogUITests.swift | 159 +++----- scripts/check-structural-budgets.py | 2 + .../Bonsplit/Internal/Views/TabBarView.swift | 81 ++-- .../Tests/BonsplitTests/BonsplitTests.swift | 14 + 36 files changed, 1746 insertions(+), 518 deletions(-) create mode 100644 Sources/AppDelegate+StartupHandoff.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36fa4cea..48e43040 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -207,6 +207,9 @@ jobs: sudo xcode-select -s "$XCODE_DIR" || true xcodebuild -version + - name: Verify native tab-strip hit testing + run: swift test --package-path vendor/bonsplit --filter BonsplitTests/testTabBarDragZoneUsesSuperviewCoordinatesAndYieldsToControls + - name: Validate Release reload artifact discovery run: ./tests/test_reloadp_programa_artifact.sh diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 96095b41..d4e0fb5a 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -25,6 +25,7 @@ on: default: "macos-15" type: choice options: + - macos-26 - macos-15 - macos-14 diff --git a/CHANGELOG.md b/CHANGELOG.md index 04ca7ae2..5dfb2cf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,12 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p - Each ship now deletes promoted release candidates older than the two most recent, so the releases page stops accumulating 110 MB prereleases. ### Fixed +- Closing a window keeps its sessions available for reopening from the Dock or New Window. Explicitly closing a workspace still ends its sessions. +- Failed session saves cancel quitting, and replacement instances wait for the previous instance to finish saving before restoring sessions. +- Provider usage uses a compact layout with consistent remaining-capacity bars. Empty window chrome and tab strips support native dragging and the system titlebar double-click action. +- Autosave acknowledges completed disk writes, retries failures, and preserves prompt-save requests during an ongoing write. Saved sessions no longer expire solely because they remained unclaimed for an hour; fresh-shell recovery is labeled explicitly. +- Scrollback restoration tracks effective terminal colors without growing style history. Fully hidden windows can release all idle terminal graphics while retaining their sessions. +- Worktrees keep their requested workspace parent across session restore, and selected Solid Fill rows retain their workspace color rail. - Idle CPU with an open window dropped from roughly 12 to 20 percent of a core to about 1 percent. The workspace pane overlay kept a display-rate animation timeline running for the life of every window; it now mounts one only while an attention flash is animating. - The dock icon no longer costs 32 MB of resident memory. The light and dark icon assets are declared as 512pt @2x, so AppKit decodes them at 1024 pixels instead of rasterizing a 2048 pixel copy. - Closing a workspace no longer leaves its Workspace object alive through the sidebar row's hover closure. diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 451acfc4..b42c3221 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -109,6 +109,7 @@ A5001006 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5001016 /* GhosttyKit.xcframework */; }; A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; }; H1AP0002 /* AppLifecycleCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = H1AP0001 /* AppLifecycleCoordinator.swift */; }; + H1SH0002 /* AppDelegate+StartupHandoff.swift in Sources */ = {isa = PBXBuildFile; fileRef = H1SH0001 /* AppDelegate+StartupHandoff.swift */; }; A5FF0014 /* TerminalController+BrowserAutomation.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0004 /* TerminalController+BrowserAutomation.swift */; }; H1BR0002 /* BrowserRPCDispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = H1BR0001 /* BrowserRPCDispatcher.swift */; }; NRTC0011 /* TerminalController+System.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRTC0001 /* TerminalController+System.swift */; }; @@ -514,6 +515,7 @@ CESH000003 /* session_escrow_shim.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = session_escrow_shim.h; sourceTree = ""; }; A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = ""; }; H1AP0001 /* AppLifecycleCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLifecycleCoordinator.swift; sourceTree = ""; }; + H1SH0001 /* AppDelegate+StartupHandoff.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+StartupHandoff.swift"; sourceTree = ""; }; A5FF0004 /* TerminalController+BrowserAutomation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+BrowserAutomation.swift"; sourceTree = ""; }; H1BR0001 /* BrowserRPCDispatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserRPCDispatcher.swift; sourceTree = ""; }; NRTC0001 /* TerminalController+System.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+System.swift"; sourceTree = ""; }; @@ -985,6 +987,7 @@ D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */, A5001090 /* AppDelegate.swift */, H1AP0001 /* AppLifecycleCoordinator.swift */, + H1SH0001 /* AppDelegate+StartupHandoff.swift */, C5E68FD3C9918B5717603304 /* SessionAutosaveCoordinator.swift */, DDE5A5F043A04829779CF356 /* ShortcutRouting.swift */, NRAD0001 /* MainWindowHostingView.swift */, @@ -1507,6 +1510,7 @@ A5FF0008 /* HostedViewPortalRegistry.swift in Sources */, A5001007 /* TerminalController.swift in Sources */, H1AP0002 /* AppLifecycleCoordinator.swift in Sources */, + H1SH0002 /* AppDelegate+StartupHandoff.swift in Sources */, A5FF0014 /* TerminalController+BrowserAutomation.swift in Sources */, H1BR0002 /* BrowserRPCDispatcher.swift in Sources */, NRTC0011 /* TerminalController+System.swift in Sources */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 5541229a..29cdda15 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -7008,13 +7008,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "The existing Programa instance is not responding. Force closing it may lose unsaved terminal or session state." + "value": "The existing Programa instance did not quit. Force closing it may lose unsaved terminal or session state." } }, "ja": { "stringUnit": { "state": "translated", - "value": "既存のProgramaインスタンスが応答していません。強制終了すると、保存されていないターミナルまたはセッションの状態が失われる可能性があります。" + "value": "既存のProgramaインスタンスは終了しませんでした。強制終了すると、保存されていないターミナルやセッションの状態が失われる可能性があります。" } } } @@ -7025,13 +7025,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Programa Isn’t Responding" + "value": "Existing Programa Is Still Open" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Programaが応答していません" + "value": "既存の Programa が開いたままです" } } } @@ -13051,13 +13051,64 @@ "en": { "stringUnit": { "state": "translated", - "value": "Programa quit unexpectedly last time. Your sessions and running processes were restored." + "value": "Programa quit unexpectedly last time. Saved workspaces are being restored and available processes reconnected. Terminals that cannot reconnect start a new shell." } }, "ja": { "stringUnit": { "state": "translated", - "value": "前回、Programa は予期せず終了しました。セッションと実行中のプロセスは復元されています。" + "value": "前回、Programa は予期せず終了しました。保存されたワークスペースを復元し、利用可能なプロセスに再接続しています。再接続できないターミナルでは新しいシェルを起動します。" + } + } + } + }, + "session_recovery.freshShell.notice": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A new shell was started because the previous process could not be reconnected." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "以前のプロセスに再接続できなかったため、新しいシェルを起動しました。" + } + } + } + }, + "dialog.quitSaveFailed.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn’t Save Sessions" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッションを保存できませんでした" + } + } + } + }, + "dialog.quitSaveFailed.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Programa stayed open because it couldn’t save your sessions. Check available disk space and try quitting again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッションを保存できなかったため、Programa は終了せずに開いたままです。ディスクの空き容量を確認して、もう一度終了してください。" } } } diff --git a/Sources/AppDelegate+StartupHandoff.swift b/Sources/AppDelegate+StartupHandoff.swift new file mode 100644 index 00000000..5efb4a1d --- /dev/null +++ b/Sources/AppDelegate+StartupHandoff.swift @@ -0,0 +1,75 @@ +import AppKit +import Darwin + +@MainActor +final class StartupSessionHandoff { + private let olderProcess: @MainActor () -> ProgramaSingleInstanceProcessKey? + private let isLive: (ProgramaSingleInstanceProcessKey) -> Bool + private let onReady: @MainActor () -> Void + private(set) var hasCompletedInitialArbitration: Bool + private var checked = false + private var target: ProgramaSingleInstanceProcessKey? + var isWaiting: Bool { target != nil } + + init( + hasCompletedInitialArbitration: Bool = SessionMachineryGate.isUnitTesting, + olderProcess: @escaping @MainActor () -> ProgramaSingleInstanceProcessKey?, + isLive: @escaping (ProgramaSingleInstanceProcessKey) -> Bool, + onReady: @escaping @MainActor () -> Void + ) { + self.hasCompletedInitialArbitration = hasCompletedInitialArbitration + self.olderProcess = olderProcess + self.isLive = isLive + self.onReady = onReady + } + + func shouldDefer() -> Bool { + guard hasCompletedInitialArbitration else { return true } + guard !checked else { return isWaiting } + checked = true + target = olderProcess() + if isWaiting { schedulePoll() } + return isWaiting + } + + func initialArbitrationCompleted() { + guard !hasCompletedInitialArbitration else { return } + hasCompletedInitialArbitration = true + if !shouldDefer() { onReady() } + } + + func poll() { + guard isWaiting else { return } + if target.map(isLive) == true { schedulePoll(); return } + target = olderProcess() + if isWaiting { schedulePoll() } else { onReady() } + } + + private func schedulePoll() { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + Task { @MainActor [weak self] in self?.poll() } + } + } + + static func authenticatedOlderProcess() -> ProgramaSingleInstanceProcessKey? { + guard !SessionMachineryGate.isUnitTesting, let bundleIdentifier = Bundle.main.bundleIdentifier, + let currentKey = AppDelegate.singleInstanceProcessKey(for: getpid()) else { return nil } + let embeddedCLIURL = Bundle.main.bundleURL + .appendingPathComponent("Contents/Resources/bin/programa", isDirectory: false) + .standardizedFileURL.resolvingSymlinksInPath() + for app in NSRunningApplication.runningApplications(withBundleIdentifier: bundleIdentifier) { + guard AppDelegate.shouldConsiderDuplicateApplication( + candidateBundleIdentifier: app.bundleIdentifier, + candidateProcessIdentifier: app.processIdentifier, + candidateExecutableURL: app.executableURL, + expectedBundleIdentifier: bundleIdentifier, + currentProcessIdentifier: currentKey.processIdentifier, + embeddedCLIURL: embeddedCLIURL + ), let otherKey = AppDelegate.singleInstanceProcessKey(for: app.processIdentifier), + AppDelegate.shouldTerminateDuplicateInstance(current: currentKey, other: otherKey), + AppDelegate.isAuthenticatedProgramaApplication(expectedProcessKey: otherKey) else { continue } + return otherKey + } + return nil + } +} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index c4ac52b6..4bc3b0bd 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -793,6 +793,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser let sidebarState: SidebarState let sidebarSelectionState: SidebarSelectionState weak var window: NSWindow? + // SwiftUI owns the primary window; keep it alive while it is ordered out. + var hiddenWindow: NSWindow? + var hiddenAt: Date? weak var observedWindow: NSWindow? var willCloseObserver: NSObjectProtocol? var willCloseObserverGeneration: UUID? @@ -911,6 +914,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } method_exchangeImplementations(originalMethod, swizzledMethod) }() + private static let didInstallWindowCloseSwizzle: Void = { + guard let original = class_getInstanceMethod(NSWindow.self, #selector(NSWindow.close)), + let replacement = class_getInstanceMethod(NSWindow.self, #selector(NSWindow.programa_close)) else { return } + method_exchangeImplementations(original, replacement) + }() private static let didInstallApplicationSendEventSwizzle: Void = { let targetClass: AnyClass = NSApplication.self let originalSelector = #selector(NSApplication.sendEvent(_:)) @@ -946,7 +954,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser let desiredFocus: Bool let isFirstResponder: Bool } - var debugCloseMainWindowConfirmationHandler: ((NSWindow) -> Bool)? var debugCreateMainWindowSourceIsNativeFullScreenOverride: Bool? // Keep debug-only windows alive when tests intentionally inject key mismatches. private var debugDetachedContextWindows: [NSWindow] = [] @@ -990,6 +997,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser #endif var mainWindowContexts: [ObjectIdentifier: MainWindowContext] = [:] + private var disposingMainWindows: Set = [] private var mainWindowControllers: [MainWindowController] = [] /// Tracks the cascade point for new windows, matching Ghostty's upstream algorithm. @@ -999,6 +1007,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser private var didPrepareStartupSessionSnapshot = false private var didAttemptStartupSessionRestore = false private var isApplyingStartupSessionRestore = false + private lazy var startupHandoff = StartupSessionHandoff( + olderProcess: StartupSessionHandoff.authenticatedOlderProcess, + isLive: { Self.singleInstanceProcessKey(for: $0.processIdentifier) == $0 }, + onReady: { [weak self] in self?.resumeStartupSessionAfterHandoff() } + ) + private var startupHandoffPrimaryWindowId: UUID? + private var acknowledgedDuplicateShutdown: (target: ProgramaSingleInstanceProcessKey, generation: UUID, url: URL)? private let sessionPersistenceQueue = DispatchQueue( label: "com.cmuxterm.app.sessionPersistence", qos: .utility @@ -1012,7 +1027,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser qos: .utility ), snapshotProvider: { _ in nil }, - saveSnapshot: { _, _ in false }, + saveSnapshot: { _, _, completion in completion(false) }, isTerminating: { false }, isRunningUnderXCTest: { false } ) @@ -1026,6 +1041,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser private var didHandleExplicitOpenIntentAtStartup = false private let appLifecycleCoordinator = AppLifecycleCoordinator() private var isTerminatingApp: Bool { appLifecycleCoordinator.isTerminating } +#if DEBUG + var debugSessionSnapshotSaverForTesting: ((AppSessionSnapshot) -> Bool)? + var debugQuitSaveFailureAlertForTesting: (() -> Void)? + + func debugResetTerminationForTesting() { + appLifecycleCoordinator.cancelTermination() + SessionMachineryGate.isApplicationTerminating = false + } +#endif private static let commandPaletteRequestGraceInterval: TimeInterval = 1.25 private static let commandPalettePendingOpenMaxAge: TimeInterval = 8.0 @@ -1088,11 +1112,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser snapshotProvider: { [weak self] includeScrollback in self?.buildSessionSnapshot(includeScrollback: includeScrollback) }, - saveSnapshot: { [weak self] includeScrollback, prebuiltSnapshot in - self?.saveSessionSnapshot( + saveSnapshot: { [weak self] includeScrollback, prebuiltSnapshot, completion in + guard let self else { + completion(false) + return + } + self.saveSessionSnapshot( includeScrollback: includeScrollback, - prebuiltSnapshot: prebuiltSnapshot - ) ?? false + prebuiltSnapshot: prebuiltSnapshot, + completion: completion + ) }, isTerminating: { [weak self] in self?.isTerminatingApp ?? false }, isRunningUnderXCTest: { [weak self] in @@ -1160,6 +1189,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser guard let self else { return } self.scheduleLaunchServicesBundleRegistration() self.enforceSingleInstance() + self.startupHandoff.initialArbitrationCompleted() self.observeDuplicateLaunches() } } else if forceDuplicateLaunchObserver { @@ -1457,6 +1487,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + if !startupHandoff.hasCompletedInitialArbitration { + enforceSingleInstance() + startupHandoff.initialArbitrationCompleted() + } + if startupHandoff.isWaiting { appLifecycleCoordinator.confirmSingleInstanceLoser() } // Validate the exact-process arbitration request and publish an acknowledgment before // synchronous persistence begins. Requesters treat that acknowledgment as proof that // this process is responsive and cannot prompt or force-close us during teardown. @@ -1473,7 +1508,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser // A warning dialog can still cancel this termination request. The final // `applicationWillTerminate` callback is the only point that records a clean exit. if terminationPolicy.persistPreTerminationSnapshot { - _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + let saved = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + if !saved && !mainWindowContexts.isEmpty { + revokeAcknowledgedDuplicateShutdown() + appLifecycleCoordinator.cancelTermination() + SessionMachineryGate.isApplicationTerminating = false + dilog("session.save", "outcome=quit_cancelled reason=snapshot_write_failed") +#if DEBUG + if let debugQuitSaveFailureAlertForTesting { + debugQuitSaveFailureAlertForTesting() + return .terminateCancel + } +#endif + let alert = NSAlert() + alert.alertStyle = .critical + alert.messageText = String(localized: "dialog.quitSaveFailed.title", defaultValue: "Couldn’t Save Sessions") + alert.informativeText = String( + localized: "dialog.quitSaveFailed.message", + defaultValue: "Programa stayed open because it couldn’t save your sessions. Check available disk space and try quitting again." + ) + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + alert.runModal() + return .terminateCancel + } } guard lifecycleDecision.shouldWarn else { @@ -1503,6 +1560,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser if shouldQuit { self.appLifecycleCoordinator.confirmQuit() } else { + self.revokeAcknowledgedDuplicateShutdown() // Reset so that the next quit attempt can show the dialog again. self.appLifecycleCoordinator.cancelTermination() // Must be reset in lockstep, or a cancelled quit would leave @@ -1594,12 +1652,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser private func prepareStartupSessionSnapshotIfNeeded() { guard !didPrepareStartupSessionSnapshot else { return } + guard !startupHandoff.shouldDefer() else { return } didPrepareStartupSessionSnapshot = true // Archive whatever the previous launch left behind before any code path below (or // later in startup) can overwrite it -- including a launch that skips restore entirely // (explicit open intent), which otherwise clobbers the file with no way back. SessionPersistenceStore.rotateIntoHistory() - guard SessionRestorePolicy.shouldAttemptRestore() else { return } + guard !didHandleExplicitOpenIntentAtStartup, SessionRestorePolicy.shouldAttemptRestore() else { return } Self.removeLegacyPersistedWindowGeometry() startupSessionSnapshot = SessionPersistenceStore.loadWithHistoryFallback() } @@ -1690,6 +1749,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser private func attemptStartupSessionRestoreIfNeeded(primaryWindow: NSWindow) { guard !didAttemptStartupSessionRestore else { return } + guard !startupHandoff.shouldDefer() else { return } + prepareStartupSessionSnapshotIfNeeded() didAttemptStartupSessionRestore = true guard !didHandleExplicitOpenIntentAtStartup else { return } guard let primaryContext = contextForMainTerminalWindow(primaryWindow) else { return } @@ -1773,7 +1834,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser ), body: String( localized: "crash_recovery.notification.body", - defaultValue: "Programa quit unexpectedly last time. Your sessions and running processes were restored." + defaultValue: "Programa quit unexpectedly last time. Saved workspaces are being restored and available processes reconnected. Terminals that cannot reconnect start a new shell." ) ) } @@ -1785,6 +1846,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser reconcileOrphanedEscrowedSessions() } + private func resumeStartupSessionAfterHandoff() { + guard !isTerminatingApp else { return } + prepareStartupSessionSnapshotIfNeeded() + let primary = mainWindowContexts.values.first(where: { $0.windowId == startupHandoffPrimaryWindowId }) + ?? mainWindowContexts.values.first + if let primary, let window = primary.window ?? windowForMainWindowId(primary.windowId) { + attemptStartupSessionRestoreIfNeeded(primaryWindow: window) + _ = saveSessionSnapshot(includeScrollback: false) + } + } + /// Issue #307 orphan-reconciliation fix: the coarse-snapshot restore /// that just completed above is keyed entirely by the panel UUIDs /// already present in `session-.json` -- if that snapshot was @@ -1916,7 +1988,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser // Every revive attempt failed: nothing to show, and leaving an // empty, oddly-titled window open with only blank placeholder // shells would just confuse whoever opens it next. - resolvedWindow(for: context)?.close() + if let window = resolvedWindow(for: context) { disposeMainWindow(window) } return (windowId: windowId, recoveredCount: 0) } @@ -2423,13 +2495,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser ProcessInfo.processInfo.enableSuddenTermination() } + /// A synchronous save returns the disk-write result; an asynchronous save returns queue + /// acceptance and reports the completed disk write through its main-queue completion. @discardableResult private func saveSessionSnapshot( includeScrollback: Bool, removeWhenEmpty: Bool = false, cleanShutdown: Bool = false, - prebuiltSnapshot: AppSessionSnapshot? = nil + prebuiltSnapshot: AppSessionSnapshot? = nil, + completion: ((Bool) -> Void)? = nil ) -> Bool { + if startupHandoff.shouldDefer() { + completion?(false) + return false + } if Self.shouldSkipSessionSaveDuringStartupRestore( isApplyingStartupSessionRestore: isApplyingStartupSessionRestore, includeScrollback: includeScrollback @@ -2437,6 +2516,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser #if DEBUG dlog("session.save.skipped reason=startup_restore_in_progress includeScrollback=0") #endif + completion?(false) return false } @@ -2456,12 +2536,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser #endif guard let snapshot = prebuiltSnapshot ?? buildSessionSnapshot(includeScrollback: includeScrollback, cleanShutdown: cleanShutdown) else { - persistSessionSnapshot( + _ = persistSessionSnapshot( nil, removeWhenEmpty: removeWhenEmpty, persistedGeometryData: nil, synchronously: writeSynchronously ) + completion?(false) return false } @@ -2475,13 +2556,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser #if DEBUG debugLogSessionSaveSnapshot(snapshot, includeScrollback: includeScrollback) #endif - persistSessionSnapshot( + return persistSessionSnapshot( snapshot, removeWhenEmpty: false, persistedGeometryData: persistedGeometryData, - synchronously: writeSynchronously + synchronously: writeSynchronously, + completion: completion ) - return true } nonisolated static func shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: Bool) -> Bool { @@ -2517,30 +2598,53 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser _ snapshot: AppSessionSnapshot?, removeWhenEmpty: Bool, persistedGeometryData: Data?, - synchronously: Bool - ) { - guard snapshot != nil || removeWhenEmpty || persistedGeometryData != nil else { return } - - let writeBlock = { - Self.removeLegacyPersistedWindowGeometry() - if let persistedGeometryData { - UserDefaults.standard.set( - persistedGeometryData, - forKey: Self.persistedWindowGeometryDefaultsKey - ) - } + synchronously: Bool, + completion: ((Bool) -> Void)? = nil + ) -> Bool { + guard snapshot != nil || removeWhenEmpty || persistedGeometryData != nil else { + completion?(false) + return false + } + +#if DEBUG + let saveOverride = debugSessionSnapshotSaverForTesting +#endif + // Preferences can synchronously notify main-queue observers; never write them + // from the disk queue while quit is synchronously waiting for that queue. + Self.removeLegacyPersistedWindowGeometry() + if let persistedGeometryData { + UserDefaults.standard.set(persistedGeometryData, forKey: Self.persistedWindowGeometryDefaultsKey) + } + let writeBlock = { () -> Bool in if let snapshot { - _ = SessionPersistenceStore.save(snapshot) +#if DEBUG + let saved = saveOverride?(snapshot) ?? SessionPersistenceStore.save(snapshot) +#else + let saved = SessionPersistenceStore.save(snapshot) +#endif + if !saved { dilog("session.save", "outcome=failed") } + return saved } else if removeWhenEmpty { SessionPersistenceStore.removeSnapshot() } + return true } - Self.performSessionPersistenceWrite( - on: sessionPersistenceQueue, - synchronously: synchronously, - operation: writeBlock - ) + if synchronously { + var saved = false + Self.performSessionPersistenceWrite(on: sessionPersistenceQueue, synchronously: true) { + saved = writeBlock() + } + completion?(saved) + return saved + } + Self.performSessionPersistenceWrite(on: sessionPersistenceQueue, synchronously: false) { + let saved = writeBlock() + if let completion { + DispatchQueue.main.async { completion(saved) } + } + } + return true } private func buildSessionSnapshot(includeScrollback: Bool, cleanShutdown: Bool = false) -> AppSessionSnapshot? { @@ -2721,6 +2825,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser context = newContext } installMainWindowCloseObserver(for: context, window: window) + if startupHandoff.shouldDefer() && startupHandoffPrimaryWindowId == nil { + startupHandoffPrimaryWindowId = windowId + } CommandPaletteController.windowLifecycle.reset(windowId: windowId) #if DEBUG @@ -3764,48 +3871,58 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser func closeMainWindow(windowId: UUID) -> Bool { guard let window = windowForMainWindowId(windowId) else { return false } - window.performClose(nil) + disposeMainWindow(window) return true } - private func confirmCloseMainWindow(_ window: NSWindow) -> Bool { -#if DEBUG - if let debugCloseMainWindowConfirmationHandler { - return debugCloseMainWindowConfirmationHandler(window) - } -#endif + /// Explicit workspace/panel and socket disposal still ends the owned sessions. + func disposeMainWindow(_ window: NSWindow) { + let key = ObjectIdentifier(window) + guard disposingMainWindows.insert(key).inserted else { return } + defer { disposingMainWindows.remove(key) } + contextForMainTerminalWindow(window)?.hiddenWindow = nil + window.close() + } - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = String(localized: "dialog.closeWindow.title", defaultValue: "Close window?") - alert.informativeText = String( - localized: "dialog.closeWindow.message", - defaultValue: "This will close the current window and all of its workspaces." + /// Ordinary window close hides the UI without destroying its workspaces or PTYs. + func preserveMainWindowOnClose(_ window: NSWindow) -> Bool { + guard !isTerminatingApp, + !disposingMainWindows.contains(ObjectIdentifier(window)), + let context = contextForMainTerminalWindow(window) else { return false } + context.hiddenWindow = window + context.hiddenAt = Date() + NotificationCenter.default.post( + name: .commandPaletteDismissRequested, + object: window, + userInfo: ["restoreFocus": false] ) - alert.addButton(withTitle: String(localized: "common.close", defaultValue: "Close")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) - - let alertWindow = alert.window - if let closeButton = alert.buttons.first { - alertWindow.defaultButtonCell = closeButton.cell as? NSButtonCell - alertWindow.initialFirstResponder = closeButton - DispatchQueue.main.async { - _ = alertWindow.makeFirstResponder(closeButton) - } + teardownCommandPaletteState(for: context.windowId) + dismissNotificationsPopoverIfShown() + if let panelId = browserAddressBarFocusedPanelId, + context.tabManager.tabs.contains(where: { $0.panels[panelId] != nil }) { + browserAddressBarFocusedPanelId = nil + stopBrowserOmnibarSelectionRepeat() } - - return alert.runModal() == .alertFirstButtonReturn + persistWindowGeometry(from: window) + window.orderOut(nil) + _ = saveSessionSnapshot(includeScrollback: false) + return true } @discardableResult - func closeWindowWithConfirmation(_ window: NSWindow) -> Bool { - guard isMainTerminalWindow(window) else { - window.close() - return true - } - guard confirmCloseMainWindow(window) else { return true } - window.close() - return true + func reopenMostRecentlyHiddenMainWindow(onlyIfNoVisibleMainWindows: Bool = true) -> Bool { + if onlyIfNoVisibleMainWindows, + mainWindowContexts.values.contains(where: { + guard let window = $0.window else { return false } + return window.isVisible || window.isMiniaturized + }) { return false } + guard let context = mainWindowContexts.values + .filter({ $0.hiddenWindow != nil }) + .max(by: { ($0.hiddenAt ?? .distantPast) < ($1.hiddenAt ?? .distantPast) }), + let window = context.hiddenWindow else { return false } + CommandPaletteController.windowLifecycle.reset(windowId: context.windowId) + bringToFront(window) + return window.isVisible } private func orderedMainWindowSummaries(referenceWindowId: UUID?) -> [MainWindowSummary] { @@ -4435,9 +4552,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } @objc func openNewMainWindow(_ sender: Any?) { + if reopenMostRecentlyHiddenMainWindow() { return } _ = createMainWindow() } + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + // AppKit's flag includes Settings and other auxiliary windows. + if reopenMostRecentlyHiddenMainWindow() { return false } + return true + } + /// Shows the "Open Folder" panel and creates a workspace for the selected directory. /// Called from both the SwiftUI menu and `handleCustomShortcut`. func showOpenFolderPanel() { @@ -5845,6 +5969,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } static func installWindowResponderSwizzlesForTesting() { + _ = didInstallWindowCloseSwizzle _ = didInstallWindowKeyEquivalentSwizzle _ = didInstallWindowFirstResponderSwizzle _ = didInstallWindowSendEventSwizzle @@ -5863,6 +5988,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser #endif private func installWindowResponderSwizzles() { + _ = Self.didInstallWindowCloseSwizzle _ = Self.didInstallApplicationSendEventSwizzle _ = Self.didInstallWindowKeyEquivalentSwizzle _ = Self.didInstallWindowFirstResponderSwizzle @@ -6954,6 +7080,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser private func handleNewTabShortcutAction(event: NSEvent) -> Bool? { guard matchConfiguredShortcut(event: event, action: .newTab) else { return nil } + if reopenMostRecentlyHiddenMainWindow() { return true } #if DEBUG dlog("shortcut.action name=newWorkspace \(debugShortcutRouteSnapshot(event: event))") #endif @@ -7198,7 +7325,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser NSSound.beep() return true } - closeWindowWithConfirmation(targetWindow) + targetWindow.close() return true } @@ -8757,6 +8884,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser enum SingleInstanceFallbackAction: Equatable, Sendable { case skip + case waitForAcknowledgedExit case prompt case force case exitNewer @@ -8938,12 +9066,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser isTerminated: Bool, response: SingleInstanceForcePromptResponse? ) -> SingleInstanceFallbackAction { - guard !hasValidTargetAcknowledgment, - requestGenerationIsPending, + guard requestGenerationIsPending, processIdentityMatches, !isTerminated else { return .skip } + if hasValidTargetAcknowledgment { return .waitForAcknowledgedExit } guard let response else { return .prompt } switch response { case .forceClose: return .force @@ -9038,7 +9166,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser return singleInstanceCodeIdentity(for: signingInformationCode) } - nonisolated private static func isAuthenticatedProgramaApplication( + nonisolated static func isAuthenticatedProgramaApplication( expectedProcessKey: ProgramaSingleInstanceProcessKey ) -> Bool { let processIdentifier = expectedProcessKey.processIdentifier @@ -9528,8 +9656,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } @discardableResult - nonisolated private static func removeExactAcknowledgment( + nonisolated static func removeExactAcknowledgment( target: ProgramaSingleInstanceProcessKey, + acceptedGeneration: UUID? = nil, url: URL ) -> Bool { guard let acknowledgment = readBoundedSingleInstanceJSON( @@ -9539,7 +9668,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser return !FileManager.default.fileExists(atPath: url.path) } guard acknowledgment.version == SingleInstanceShutdownAcknowledgment.currentVersion, - acknowledgment.target == target else { + acknowledgment.target == target, + acceptedGeneration == nil || acknowledgment.acceptedGeneration == acceptedGeneration else { return false } do { @@ -9791,6 +9921,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser dilog("single_instance", "pid=\(currentProcessIdentifier) outcome=rejected reason=ack_write") continue } + acknowledgedDuplicateShutdown = ( + currentKey, request.generation, + Self.duplicateShutdownAcknowledgmentURL(rootDirectory: directoryURL, target: currentKey) + ) dilog("single_instance", "pid=\(currentProcessIdentifier) outcome=accepted reason=shutdown_request") return true } @@ -9798,6 +9932,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser return false } + private func revokeAcknowledgedDuplicateShutdown() { + guard let ack = acknowledgedDuplicateShutdown else { return } + _ = Self.removeExactAcknowledgment( + target: ack.target, acceptedGeneration: ack.generation, url: ack.url + ) + acknowledgedDuplicateShutdown = nil + } + private static func duplicateFallbackState( app: NSRunningApplication, pending: PendingSingleInstanceShutdown, @@ -9824,11 +9966,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser alert.alertStyle = .critical alert.messageText = String( localized: "dialog.singleInstanceNotResponding.title", - defaultValue: "Programa Isn’t Responding" + defaultValue: "Existing Programa Is Still Open" ) alert.informativeText = String( localized: "dialog.singleInstanceNotResponding.message", - defaultValue: "The existing Programa instance is not responding. Force closing it may lose unsaved terminal or session state." + defaultValue: "The existing Programa instance did not quit. Force closing it may lose unsaved terminal or session state." ) let cancelButton = alert.addButton( withTitle: String(localized: "common.cancel", defaultValue: "Cancel") @@ -9857,6 +9999,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser ) { let processIdentifier = pending.request.target.processIdentifier let (initialAction, _) = duplicateFallbackState(app: app, pending: pending, response: nil) + if initialAction == .waitForAcknowledgedExit { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { @MainActor in handleDuplicateShutdownFallback(app: app, pending: pending) } + return + } guard initialAction == .prompt else { removeExactShutdownState(pending) dilog("single_instance", "pid=\(processIdentifier) outcome=skipped reason=fallback_revalidated") @@ -9898,6 +10044,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser case .skip: removeExactShutdownState(pending) dilog("single_instance", "pid=\(processIdentifier) outcome=skipped reason=post_prompt_revalidation") + case .waitForAcknowledgedExit: + handleDuplicateShutdownFallback(app: app, pending: pending) case .prompt: removeExactShutdownState(pending) dilog("single_instance", "pid=\(processIdentifier) outcome=skipped reason=invalid_prompt_state") @@ -10183,6 +10331,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser private func setActiveMainWindow(_ window: NSWindow) { guard let context = contextForMainTerminalWindow(window) else { return } + if window.isVisible { + context.hiddenWindow = nil + context.hiddenAt = nil + } #if DEBUG let beforeManagerToken = debugManagerToken(tabManager) #endif @@ -10309,7 +10461,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser guard let context = contextContainingTabId(tabId) else { return } let expectedIdentifier = "cmux.main.\(context.windowId.uuidString)" let window: NSWindow? = context.window ?? NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier }) - window?.performClose(nil) + if let window { disposeMainWindow(window) } } @discardableResult @@ -10574,6 +10726,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser window.deminiaturize(nil) } window.makeKeyAndOrderFront(nil) + if let context = contextForMainTerminalWindow(window) { + context.hiddenWindow = nil + context.hiddenAt = nil + } // Improve reliability across Spaces / when other helper panels are key. NSRunningApplication.current.activate(options: [.activateAllWindows]) } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 1ac7464a..6f458b91 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -609,9 +609,7 @@ struct ContentView: View { ZStack { // Enable window dragging from the titlebar strip without making the entire content // view draggable (which breaks drag gestures like tab reordering). - // Double-click on the strip opens a tab instead of the standard - // zoom/minimize titlebar action. - WindowDragHandleView(onDoubleClick: { tabManager.addTab() }) + WindowDragHandleView() TitlebarLeadingInsetReader(inset: $titlebarLeadingInset) .allowsHitTesting(false) @@ -770,14 +768,8 @@ struct ContentView: View { ZStack(alignment: .leading) { terminalContentWithSidebarDropOverlay .padding(cardInsetAmount) - // Dead chrome surface: the gap ring around the content card - // (window edges, and the top gap when card-layout+sidebar-visible - // has no customTitlebar). Background sits behind the real - // terminal content, which only occupies the inset interior, so - // AppKit hit-testing falls through to this handle in the ring. - // Double-click on the chrome ring opens a tab instead of the - // standard zoom/minimize titlebar action. - .background(WindowDragHandleView(onDoubleClick: { tabManager.addTab() })) + // Empty inset chrome drags without covering the tab controls. + .background(WindowDragHandleView()) .padding(.leading, sidebarState.isVisible ? sidebarWidth : 0) if sidebarState.isVisible { sidebarView @@ -793,8 +785,7 @@ struct ContentView: View { } terminalContentWithSidebarDropOverlay .padding(cardInsetAmount) - // See comment in the useWithinWindow branch above. - .background(WindowDragHandleView(onDoubleClick: { tabManager.addTab() })) + .background(WindowDragHandleView()) } ) } @@ -1131,7 +1122,7 @@ struct ContentView: View { keyWindow: NSApp.keyWindow, mainWindow: NSApp.mainWindow ) else { return } - dismissCommandPalette() + dismissCommandPalette(restoreFocus: notification.userInfo?["restoreFocus"] as? Bool ?? true) } .onReceive(NotificationCenter.default.publisher(for: .commandPaletteRenameTabRequested)) { notification in let requestedWindow = notification.object as? NSWindow @@ -4287,11 +4278,7 @@ struct ContentView: View { NSSound.beep() return } - if let appDelegate = AppDelegate.shared { - appDelegate.closeWindowWithConfirmation(window) - } else { - window.performClose(nil) - } + window.close() } registry.register(commandId: "palette.toggleFullScreen") { guard let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow else { @@ -5162,6 +5149,12 @@ struct ContentView: View { private func attemptCommandPaletteFocusRestoreIfNeeded() { guard !commandPaletteController.isCommandPalettePresented else { return } guard let target = commandPaletteController.commandPalettePendingDismissFocusTarget else { return } + if let window = observedWindow, !window.isVisible { + commandPaletteController.commandPalettePendingDismissFocusTarget = nil + commandPaletteController.commandPaletteRestoreTimeoutWorkItem?.cancel() + commandPaletteController.commandPaletteRestoreTimeoutWorkItem = nil + return + } guard tabManager.tabs.contains(where: { $0.id == target.workspaceId }) else { commandPaletteController.commandPalettePendingDismissFocusTarget = nil commandPaletteController.commandPaletteRestoreTimeoutWorkItem?.cancel() diff --git a/Sources/ProgramaApp.swift b/Sources/ProgramaApp.swift index 8df4e9eb..40b8506c 100644 --- a/Sources/ProgramaApp.swift +++ b/Sources/ProgramaApp.swift @@ -512,6 +512,7 @@ struct programaApp: App { splitCommandButton(title: String(localized: "menu.file.newWorkspace", defaultValue: "New Workspace"), shortcut: menuShortcut(for: .newTab)) { if let appDelegate = AppDelegate.shared { + if appDelegate.reopenMostRecentlyHiddenMainWindow() { return } if appDelegate.addWorkspaceInPreferredMainWindow(debugSource: "menu.newWorkspace") == nil { #if DEBUG FocusLogStore.shared.append( diff --git a/Sources/RendererRealization.swift b/Sources/RendererRealization.swift index 3df6b1b8..a3a6e268 100644 --- a/Sources/RendererRealization.swift +++ b/Sources/RendererRealization.swift @@ -37,7 +37,8 @@ enum RendererRealizationPlanner { return lhs.lastVisibleAt > rhs.lastVisibleAt } - let warmCap = max(1, settings.maxWarmRenderers) + // Closed/hidden windows keep PTYs alive but need no permanently warm swap chain. + let warmCap = inputs.contains(where: \.isVisible) ? max(1, settings.maxWarmRenderers) : 0 var selected: Set = [] for (index, input) in ranked.enumerated() { if index < warmCap || input.isVisible { continue } diff --git a/Sources/SessionAutosaveCoordinator.swift b/Sources/SessionAutosaveCoordinator.swift index 78af491d..99472876 100644 --- a/Sources/SessionAutosaveCoordinator.swift +++ b/Sources/SessionAutosaveCoordinator.swift @@ -10,13 +10,20 @@ import Bonsplit /// isolation with fakes. final class SessionAutosaveCoordinator { typealias SnapshotProvider = (_ includeScrollback: Bool) -> AppSessionSnapshot? - typealias SnapshotSaver = (_ includeScrollback: Bool, _ prebuiltSnapshot: AppSessionSnapshot?) -> Bool + /// Completion runs on main and reports the completed disk write, not queue acceptance. + typealias SnapshotSaver = ( + _ includeScrollback: Bool, + _ prebuiltSnapshot: AppSessionSnapshot?, + _ completion: @escaping (Bool) -> Void + ) -> Void typealias TerminatingProvider = () -> Bool typealias XCTestRunningProvider = () -> Bool private var sessionAutosaveTimer: DispatchSourceTimer? private var sessionAutosaveTickInFlight = false + private var autosaveGeneration = UUID() private var promptSaveScheduled = false + private var promptSaveWaitingForWrite = false private var consecutiveDeclinedSaveRetries = 0 private static let maxConsecutiveDeclinedSaveRetries = 5 private var sessionAutosaveDeferredRetryPending = false @@ -88,8 +95,12 @@ final class SessionAutosaveCoordinator { func stopSessionAutosaveTimer() { sessionAutosaveTimer?.cancel() sessionAutosaveTimer = nil + autosaveGeneration = UUID() sessionAutosaveTickInFlight = false sessionAutosaveDeferredRetryPending = false + promptSaveScheduled = false + promptSaveWaitingForWrite = false + consecutiveDeclinedSaveRetries = 0 } nonisolated static func shouldRunSessionAutosaveTick(isTerminatingApp: Bool) -> Bool { @@ -109,9 +120,10 @@ final class SessionAutosaveCoordinator { guard delay.isFinite, delay > 0 else { return } guard !sessionAutosaveDeferredRetryPending else { return } sessionAutosaveDeferredRetryPending = true + let generation = autosaveGeneration sessionPersistenceQueue.asyncAfter(deadline: .now() + delay) { [weak self] in Task { @MainActor [weak self] in - guard let self else { return } + guard let self, self.autosaveGeneration == generation else { return } self.sessionAutosaveDeferredRetryPending = false self.runSessionAutosaveTick(source: "typingQuietRetry") } @@ -139,7 +151,6 @@ final class SessionAutosaveCoordinator { var fingerprintMs: Double = 0 var saveMs: Double = 0 defer { - sessionAutosaveTickInFlight = false let totalMs = (ProcessInfo.processInfo.systemUptime - phaseStart) * 1000.0 ProgramaTypingTiming.logBreakdown( path: "session.autosaveTick.phase", @@ -157,8 +168,6 @@ final class SessionAutosaveCoordinator { extra: "source=\(source)" ) } -#else - defer { sessionAutosaveTickInFlight = false } #endif let now = Date() @@ -180,6 +189,7 @@ final class SessionAutosaveCoordinator { lastPersistedAt: lastSessionAutosavePersistedAt, now: now ) { + sessionAutosaveTickInFlight = false #if DEBUG dlog( "session.save.skipped reason=unchanged_autosave_fingerprint includeScrollback=0 source=\(source)" @@ -191,31 +201,35 @@ final class SessionAutosaveCoordinator { #if DEBUG let saveStart = ProcessInfo.processInfo.systemUptime #endif - let saved = saveSnapshot(false, autosaveSnapshot) + let generation = autosaveGeneration + saveSnapshot(false, autosaveSnapshot) { [weak self] saved in + guard let self, self.autosaveGeneration == generation else { return } + self.sessionAutosaveTickInFlight = false + guard !self.isTerminating() else { return } + guard saved else { + // Declined snapshots and failed disk writes must remain eligible even + // when their content is unchanged. Bound retries for windowless apps + // and persistent disk errors; the periodic timer keeps trying later. + if self.consecutiveDeclinedSaveRetries < Self.maxConsecutiveDeclinedSaveRetries { + self.consecutiveDeclinedSaveRetries += 1 + self.scheduleDeferredSessionAutosaveRetry(after: 1.0) + } + return + } + self.consecutiveDeclinedSaveRetries = 0 + self.updateSessionAutosaveSaveState( + includeScrollback: false, + persistedAt: Date(), + fingerprint: autosaveFingerprint + ) + if self.promptSaveWaitingForWrite { + self.promptSaveWaitingForWrite = false + self.requestPromptSave(source: "writeCompleted", after: 0) + } + } #if DEBUG saveMs = (ProcessInfo.processInfo.systemUptime - saveStart) * 1000.0 #endif - guard saved else { - // The save layer can decline (startup restore still in flight, empty - // snapshot). Recording the fingerprint anyway would suppress up to - // 60s of identical-content saves after a save that never happened, - // and a declined prompt save reopened the escrow shadow gap it was - // built to close (audit 2026-08-20, M3). Retry, bounded: the - // restore-in-flight decline clears within a few seconds, while a - // windowless app declines indefinitely and must not become a 1s - // polling loop — the periodic timer remains the steady cadence. - if consecutiveDeclinedSaveRetries < Self.maxConsecutiveDeclinedSaveRetries { - consecutiveDeclinedSaveRetries += 1 - scheduleDeferredSessionAutosaveRetry(after: 1.0) - } - return - } - consecutiveDeclinedSaveRetries = 0 - updateSessionAutosaveSaveState( - includeScrollback: false, - persistedAt: now, - fingerprint: autosaveFingerprint - ) } /// Coalesced "save soon" for structural changes — a new panel finishing escrow @@ -228,9 +242,14 @@ final class SessionAutosaveCoordinator { func requestPromptSave(source: String, after delay: TimeInterval = 1.0) { guard !promptSaveScheduled else { return } promptSaveScheduled = true + let generation = autosaveGeneration DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self else { return } + guard let self, self.autosaveGeneration == generation else { return } self.promptSaveScheduled = false + if self.sessionAutosaveTickInFlight { + self.promptSaveWaitingForWrite = true + return + } self.runSessionAutosaveTick(source: source) } } diff --git a/Sources/SessionEscrow.swift b/Sources/SessionEscrow.swift index c944ffa5..2d4c86c9 100644 --- a/Sources/SessionEscrow.swift +++ b/Sources/SessionEscrow.swift @@ -207,11 +207,22 @@ enum SessionEscrowPolicy { /// or a user who quits and reopens much later, can legitimately exceed /// 10 minutes, and a false expiry silently destroys a session someone /// wanted (SIGHUP to the shell; only scrollback survives via the - /// fallback restore). One hour keeps the leak bounded while making - /// false retirement implausible for real relaunch flows. The durable - /// fix is explicit claim/renew reconciliation between app and holder, - /// not a timer -- tracked separately; this TTL is a stopgap until then. + /// fallback restore). This limit applies only to sessions absent from + /// this bundle's durable snapshot. Snapshot-owned sessions survive long + /// absences; unreadable snapshots defer expiry conservatively. static let unclaimedSessionTTL: TimeInterval = 3600 + + static func shouldExpireSession( + sessionID: String, + isDraining: Bool, + drainingStartedAt: Date?, + now: Date, + ownedSessionIDs: Set? + ) -> Bool { + guard isDraining, let drainingStartedAt, let ownedSessionIDs, + !ownedSessionIDs.contains(sessionID.uppercased()) else { return false } + return now.timeIntervalSince(drainingStartedAt) >= unclaimedSessionTTL + } /// 2026-08-10 mass-drain fix (retrieve-before-drain race): how long a /// relaunched app keeps retrying a retrieve the holder denied only /// because it has not yet detected the previous instance's death @@ -859,10 +870,12 @@ final class SessionEscrowClient { /// means a tagged debug build and the production app (or two different /// tags) always get distinct holder sockets, matching the isolation /// the rest of the socket-path machinery already guarantees. - private static func escrowSocketPath() -> String { - let base = SocketControlSettings.socketPath() - let baseURL = URL(fileURLWithPath: base) - let name = baseURL.deletingPathExtension().lastPathComponent + "-escrow" + /// Version the holder policy so upgraded apps register with the new + /// snapshot-aware reaper. Retrieval still uses the socket recorded in + /// each session's metadata, allowing old holders to hand sessions over. + static func escrowSocketPath(controlSocketPath: String = SocketControlSettings.socketPath()) -> String { + let baseURL = URL(fileURLWithPath: controlSocketPath) + let name = baseURL.deletingPathExtension().lastPathComponent + "-escrow-v2" return baseURL.deletingLastPathComponent() .appendingPathComponent(name) .appendingPathExtension("sock") @@ -1732,7 +1745,7 @@ enum SessionEscrowHolder { /// Started once from `run()`, before the accept loop, and never /// stops. Wakes every `SessionEscrowPolicy.reaperInterval` to (1) close - /// out any session that has been draining, unclaimed, past + /// out any session absent from the snapshot that has been draining past /// `SessionEscrowPolicy.unclaimedSessionTTL`, and (2) exit the holder /// once it has had nothing to hold and no live connection for /// `SessionEscrowPolicy.idleExitGrace`. Both halves of the fix for the @@ -1796,9 +1809,9 @@ enum SessionEscrowHolder { Darwin.exit(0) } - /// Closes out every draining session whose `drainingStartedAt` is - /// older than `SessionEscrowPolicy.unclaimedSessionTTL` -- no app ever - /// came back for it. Mirrors `handleRetrieveRequest`'s drain/retrieve + /// Closes out draining sessions absent from this bundle's snapshot whose + /// `drainingStartedAt` exceeds `SessionEscrowPolicy.unclaimedSessionTTL`. + /// Mirrors `handleRetrieveRequest`'s drain/retrieve /// coordination exactly (remove-from-registry, request-stop, wait on /// the semaphore, then close) so an expiry can never race a drain /// thread's in-flight read the way a bare `close()` would. Unlike a @@ -1806,14 +1819,29 @@ enum SessionEscrowHolder { /// called -- only `markClosedIfNeeded()`. private static func retireExpiredSessions() { let now = Date() + registryLock.lock() + let candidates = registry.values.filter { + $0.isDraining && $0.drainingStartedAt.map { + now.timeIntervalSince($0) >= SessionEscrowPolicy.unclaimedSessionTTL + } == true + } + registryLock.unlock() + guard !candidates.isEmpty else { return } + // Disk reads must never block registry operations or the drain threads. + let owned = SessionPersistenceStore.ownedTerminalSessionIDs() var expired: [HeldSession] = [] registryLock.lock() - for (sessionId, session) in registry { - if session.isDraining, - let startedAt = session.drainingStartedAt, - now.timeIntervalSince(startedAt) >= SessionEscrowPolicy.unclaimedSessionTTL { + for session in candidates { + if registry[session.sessionId] === session, + SessionEscrowPolicy.shouldExpireSession( + sessionID: session.sessionId, + isDraining: session.isDraining, + drainingStartedAt: session.drainingStartedAt, + now: now, + ownedSessionIDs: owned + ) { expired.append(session) - registry.removeValue(forKey: sessionId) + registry.removeValue(forKey: session.sessionId) session.stopRequested = true } } diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index ce277236..84aedb43 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -377,6 +377,8 @@ struct SessionWorkspaceSnapshot: Codable, Sendable { var isWorktreeFolder: Bool? = nil var isWorktreeFolderCollapsed: Bool? = nil var worktreeBranch: String? = nil + var id: UUID? = nil + var worktreeParentWorkspaceId: UUID? = nil } struct SessionTabManagerSnapshot: Codable, Sendable { @@ -402,6 +404,51 @@ struct AppSessionSnapshot: Codable, Sendable { enum SessionPersistenceStore { static let historyDirectoryScanLimit = 256 + /// `nil` means ownership is unknown: cleanup must preserve sessions. Only a + /// genuinely absent file or a valid snapshot can prove a session is unowned. + static func ownedTerminalSessionIDs(fileURL: URL? = nil) -> Set? { + guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return nil } + do { + _ = try FileManager.default.attributesOfItem(atPath: fileURL.path) + } catch let error as NSError { + if error.domain == NSCocoaErrorDomain, + error.code == NSFileNoSuchFileError || error.code == NSFileReadNoSuchFileError { + return [] + } + return nil + } + guard let data = boundedSnapshotData(at: fileURL), + let snapshot = decodeSnapshot(from: data), + snapshot.version == SessionSnapshotSchema.currentVersion else { return nil } + return Set(snapshot.windows.flatMap { window in + window.tabManager.workspaces.flatMap { workspace in + workspace.panels.filter { $0.type == .terminal }.map { $0.id.uuidString } + } + }) + } + + /// WAL storage is shared by production and tagged bundles. Consult every + /// current snapshot before deleting its session data, with bounded scan work. + static func allOwnedTerminalSessionIDs(in directory: URL) -> Set? { + var failed = false + guard let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants], + errorHandler: { _, _ in failed = true; return false } + ) else { return nil } + var owned = Set() + var inspected = 0 + while let entry = enumerator.nextObject() as? URL { + inspected += 1 + guard inspected <= historyDirectoryScanLimit else { return nil } + guard entry.lastPathComponent.hasPrefix("session-"), entry.pathExtension == "json" else { continue } + guard let ids = ownedTerminalSessionIDs(fileURL: entry) else { return nil } + owned.formUnion(ids) + } + return failed ? nil : owned + } + struct HistoryScanResult { let entries: [URL] let inspectedEntryCount: Int @@ -1284,12 +1331,150 @@ enum SessionFreshSpawnScrollbackSeed { return tokens } + /// Ghostty's supported SGR attributes. Keeping the + /// effective values, rather than every command since the last reset, + /// bounds both each cell's state and the text emitted for a style change. + private struct ReplayStyle: Equatable { + var bold = false + var faint = false + var italic = false + var underlineCode: String? + var blink = false + var inverse = false + var invisible = false + var strikethrough = false + var overline = false + var foreground: String? + var background: String? + var underlineColor: String? + + mutating func apply(_ sequence: String) { + let body = sequence.dropFirst(2).dropLast() + let parameters = body.split(omittingEmptySubsequences: false) { $0 == ";" || $0 == ":" } + .map { $0.isEmpty ? 0 : Int($0) } + let colonAfter = body.filter { $0 == ";" || $0 == ":" }.map { $0 == ":" } + var index = 0 + while index < parameters.count { + defer { index += 1 } + guard let code = parameters[index] else { continue } + let colon = index < colonAfter.count && colonAfter[index] + if colon && code != 4 && code != 38 && code != 48 && code != 58 { + while index < colonAfter.count && colonAfter[index] { index += 1 } + continue + } + switch code { + case 0: self = ReplayStyle() + case 1: bold = true + case 2: faint = true + case 3: italic = true + case 4: + if colon { + guard index + 1 < parameters.count, + let variant = parameters[index + 1], + !(index + 1 < colonAfter.count && colonAfter[index + 1]) else { + while index < colonAfter.count && colonAfter[index] { index += 1 } + continue + } + underlineCode = variant == 0 ? nil : "4:\(variant <= 5 ? variant : 1)" + index += 1 + } else { + underlineCode = "4" + } + case 21: underlineCode = "21" + case 5, 6: blink = true + case 7: inverse = true + case 8: invisible = true + case 9: strikethrough = true + case 22: bold = false; faint = false + case 23: italic = false + case 24: underlineCode = nil + case 25: blink = false + case 27: inverse = false + case 28: invisible = false + case 29: strikethrough = false + case 30...37, 90...97: foreground = String(code) + case 39: foreground = nil + case 40...47, 100...107: background = String(code) + case 49: background = nil + case 53: overline = true + case 55: overline = false + case 59: underlineColor = nil + case 38, 48, 58: + guard index + 1 < parameters.count, let mode = parameters[index + 1] else { continue } + var componentStart = index + 2 + if mode == 2 && colon { + var colonCount = 0 + var position = index + 1 + while position < colonAfter.count && colonAfter[position] { + colonCount += 1 + position += 1 + } + guard colonCount == 3 || colonCount == 4 else { continue } + if colonCount == 4 { componentStart += 1 } // optional colorspace + } + let componentCount = mode == 5 ? 1 : (mode == 2 ? 3 : 0) + guard componentCount > 0, + componentStart + componentCount <= parameters.count else { continue } + let components = parameters[componentStart..<(componentStart + componentCount)] + guard components.allSatisfy({ $0 != nil }) else { continue } + // Ghostty stores palette and RGB components as bytes. + let color = ([code, mode] + components.compactMap { $0 }.map { $0 & 255 }) + .map(String.init).joined(separator: ";") + switch code { + case 38: foreground = color + case 48: background = color + default: underlineColor = color + } + index = componentStart + componentCount - 1 + default: break // Ghostty ignores unsupported numeric SGRs. + } + } + } + + var sequence: String { + var codes: [String] = [] + if bold { codes.append("1") } + if faint { codes.append("2") } + if italic { codes.append("3") } + if let underlineCode { codes.append(underlineCode) } + if blink { codes.append("5") } + if inverse { codes.append("7") } + if invisible { codes.append("8") } + if strikethrough { codes.append("9") } + if overline { codes.append("53") } + if let foreground { codes.append(foreground) } + if let background { codes.append(background) } + if let underlineColor { codes.append(underlineColor) } + // Ghostty ignores a CSI with more than 24 parameters (colon subparameters + // count too). Keep complete color groups together when splitting a style. + var output = "" + var group: [String] = [] + var parameterCount = 0 + for code in codes { + let count = 1 + code.filter { $0 == ";" || $0 == ":" }.count + if parameterCount + count > 24 { + output += "\u{001B}[" + group.joined(separator: ";") + "m" + group.removeAll(keepingCapacity: true) + parameterCount = 0 + } + group.append(code) + parameterCount += count + } + if !group.isEmpty { output += "\u{001B}[" + group.joined(separator: ";") + "m" } + return output + } + } + /// A single replayed grid cell: the character last written there, and - /// the SGR state active at the moment it was written. Overwriting a cell - /// replaces both fields together, so a later redraw's color correctly - /// wins over the frame it overwrites. + /// the effective SGR state active at the moment it was written. + private final class ReplayStyleReference { + let value: ReplayStyle + + init(_ value: ReplayStyle) { self.value = value } + } + private struct ReplayCell { - var sgr: String + var style: ReplayStyleReference var char: Character } @@ -1304,16 +1489,19 @@ enum SessionFreshSpawnScrollbackSeed { /// leaves the tail of a longer previous one in place. private static func replayedLine(from tokens: [ReplayToken]) -> String { var cells: [ReplayCell] = [] + let defaultStyle = ReplayStyleReference(ReplayStyle()) var cursor = 0 - var currentSGR = "" + var currentStyle = defaultStyle for token in tokens { switch token { case .sgr(let sequence): - if sequence == "\u{001B}[0m" || sequence == "\u{001B}[m" { - currentSGR = "" - } else { - currentSGR += sequence + var style = currentStyle.value + style.apply(sequence) + if style != currentStyle.value { + // Cells share immutable styles; overwritten/erased cells release + // obsolete styles instead of retaining a line's entire history. + currentStyle = ReplayStyleReference(style) } case .char(let char): switch char { @@ -1329,12 +1517,12 @@ enum SessionFreshSpawnScrollbackSeed { cells.removeAll() cursor = 0 default: - let cell = ReplayCell(sgr: currentSGR, char: char) + let cell = ReplayCell(style: currentStyle, char: char) if cursor < cells.count { cells[cursor] = cell } else { if cursor > cells.count { - cells.append(contentsOf: repeatElement(ReplayCell(sgr: "", char: " "), count: cursor - cells.count)) + cells.append(contentsOf: repeatElement(ReplayCell(style: defaultStyle, char: " "), count: cursor - cells.count)) } cells.append(cell) } @@ -1344,16 +1532,18 @@ enum SessionFreshSpawnScrollbackSeed { } var output = "" - var lastEmittedSGR = "" + var lastEmittedStyle = defaultStyle for cell in cells { - if cell.sgr != lastEmittedSGR { - output += cell.sgr.isEmpty ? ansiReset : cell.sgr - lastEmittedSGR = cell.sgr + if cell.style.value != lastEmittedStyle.value { + if lastEmittedStyle.value != defaultStyle.value { output += ansiReset } + output += cell.style.value.sequence + lastEmittedStyle = cell.style } output.append(cell.char) } - if currentSGR != lastEmittedSGR { - output += currentSGR.isEmpty ? ansiReset : currentSGR + if currentStyle.value != lastEmittedStyle.value { + if lastEmittedStyle.value != defaultStyle.value { output += ansiReset } + output += currentStyle.value.sequence } return output } @@ -1376,14 +1566,14 @@ enum SessionFreshSpawnScrollbackSeed { return try! NSRegularExpression(pattern: esc + #"\[[12]K"#) }() - /// Matches SGR (`ESC[...m`, including the bare `ESC[m` shorthand for - /// reset). Used by `replayTokens(in:)` to recognize SGR as a unit within + /// Matches SGR (`ESC[...m`, including colon subparameters and the bare + /// `ESC[m` shorthand for reset). Used by `replayTokens(in:)` as a unit within /// otherwise-plain text -- `sanitizedWorkingText` deliberately does not /// strip these, and `csiRegex` deliberately excludes them. private static let sgrRegex: NSRegularExpression = { let esc = "\u{001B}" // swiftlint:disable:next force_try - return try! NSRegularExpression(pattern: esc + #"\[[0-9;]*m"#) + return try! NSRegularExpression(pattern: esc + #"\[[0-9;:]*m"#) }() /// OSC (`ESC]...`) terminated by BEL or ST (`ESC\`). Requiring the diff --git a/Sources/SessionWALStore.swift b/Sources/SessionWALStore.swift index d607a575..442826d6 100644 --- a/Sources/SessionWALStore.swift +++ b/Sources/SessionWALStore.swift @@ -144,9 +144,9 @@ import Bonsplit /// its `meta.json` heartbeat (or, if that can't be parsed, its own /// filesystem modification date) is older than /// `SessionWALPolicy.orphanDirectoryMaxAge`. A directory whose age cannot -/// be determined at all is kept. This only catches sessions from a run -/// further back than the current snapshot references, not anything from -/// the run that is currently restoring. +/// be determined at all is kept. Every bundle's current snapshot protects +/// its terminal directories regardless of age; unreadable snapshots defer +/// the sweep so tagged builds cannot destroy production retrieval tokens. enum SessionWALPolicy { /// Fixed capacity of the in-memory ring buffer the tee callback writes /// into. Large enough to absorb a burst between 100ms drains for normal @@ -1106,9 +1106,9 @@ final class SessionWALStore { /// own 30s reaper cadence, and mirroring that TTL here from the /// different `lastHeartbeatAt` clock is exactly the two-clocks race /// that would delete a live child's token near the boundary) and left - /// to the orphan sweep (`sweepOrphanedSessionDirectories`), whose - /// `orphanDirectoryMaxAge` (24h) comfortably outlives the holder's - /// `unclaimedSessionTTL` (1h). `force` bypasses preservation for + /// to the orphan sweep (`sweepOrphanedSessionDirectories`), which protects + /// snapshot-owned sessions and ages unowned directories for 24h, beyond + /// the holder's unowned-session TTL (1h). `force` bypasses preservation for /// callers that KNOW the claim is consumed -- the revive-success path, /// where the holder has already removed the session from its registry, /// leaving `meta.json`'s escrow fields stale-but-live-looking. @@ -1427,14 +1427,25 @@ final class SessionWALStore { private func sweepOrphanedSessionDirectories() { guard let root = SessionWALPaths.sessionsRootURL() else { return } + Self.sweepOrphanedSessionDirectories(at: root, registeredSessionIDs: Set(writersBySurfaceId.keys)) + } + + static func sweepOrphanedSessionDirectories( + at root: URL, + registeredSessionIDs: Set, + now: Date = Date() + ) { + guard let owned = SessionPersistenceStore.allOwnedTerminalSessionIDs( + in: root.deletingLastPathComponent() + ) else { return } guard let entries = try? FileManager.default.contentsOfDirectory( at: root, includingPropertiesForKeys: nil ) else { return } - let cutoff = Date().addingTimeInterval(-SessionWALPolicy.orphanDirectoryMaxAge) + let cutoff = now.addingTimeInterval(-SessionWALPolicy.orphanDirectoryMaxAge) for entry in entries { let name = entry.lastPathComponent - guard writersBySurfaceId[name] == nil else { continue } + guard !registeredSessionIDs.contains(name), !owned.contains(name.uppercased()) else { continue } guard Self.isDirectoryUnambiguouslyStale(entry, olderThan: cutoff) else { continue } try? FileManager.default.removeItem(at: entry) } diff --git a/Sources/SidebarQuotaFooter.swift b/Sources/SidebarQuotaFooter.swift index 1b8644e3..69c4236d 100644 --- a/Sources/SidebarQuotaFooter.swift +++ b/Sources/SidebarQuotaFooter.swift @@ -51,23 +51,6 @@ struct SidebarQuotaFooter: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - HStack(spacing: 8) { - Text(String(localized: "sidebar.usage.title", defaultValue: "Provider Usage")) - .font(.headline) - Spacer(minLength: 8) - if store.isRefreshing { - ProgressView() - .controlSize(.small) - .accessibilityLabel( - String(localized: "sidebar.usage.refreshing", defaultValue: "Refreshing usage") - ) - } - } - .padding(.horizontal, 14) - .padding(.vertical, 12) - - Divider() - ScrollView { VStack(alignment: .leading, spacing: 12) { if store.results.isEmpty, store.isRefreshing { @@ -78,6 +61,9 @@ struct SidebarQuotaFooter: View { } ForEach(presentation.availableSnapshots, id: \.provider) { snapshot in + if snapshot.provider != presentation.availableSnapshots.first?.provider { + Divider() + } providerSection(snapshot) } @@ -91,7 +77,17 @@ struct SidebarQuotaFooter: View { .fixedSize(horizontal: false, vertical: true) } } - .frame(width: 320) + .overlay(alignment: .topTrailing) { + if store.isRefreshing, !store.results.isEmpty { + ProgressView() + .controlSize(.mini) + .padding(14) + .accessibilityLabel( + String(localized: "sidebar.usage.refreshing", defaultValue: "Refreshing usage") + ) + } + } + .frame(width: 280) .fixedSize(horizontal: false, vertical: true) .frame(maxHeight: 480, alignment: .top) } @@ -126,26 +122,23 @@ struct SidebarQuotaFooter: View { private func providerSection(_ snapshot: ProviderUsageSnapshot) -> some View { VStack(alignment: .leading, spacing: 8) { - Text(snapshot.provider.localizedDisplayName) - .font(.system(size: 12, weight: .semibold)) - .accessibilityAddTraits(.isHeader) - - if let summaryText = Self.summaryText(snapshot.summary) { - Text(summaryText) - .font(.system(size: 10)) - .foregroundStyle(.secondary) - .lineLimit(1) + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(snapshot.provider.localizedDisplayName) + .font(.system(size: 12, weight: .semibold)) + .accessibilityAddTraits(.isHeader) + if let summaryText = Self.summaryText(snapshot.summary) { + Text(summaryText) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: store.isRefreshing ? 16 : 0) } ForEach(snapshot.windows) { window in usageRow(window) } } - .padding(10) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color(nsColor: .controlBackgroundColor).opacity(0.55)) - ) } private func usageRow(_ window: ProviderUsageWindow) -> some View { @@ -165,7 +158,7 @@ struct SidebarQuotaFooter: View { .fill(Color.secondary.opacity(0.15)) Capsule() .fill(Self.barColor(for: window.usedPercent)) - .frame(width: proxy.size.width * CGFloat(window.usedPercent) / 100) + .frame(width: proxy.size.width * CGFloat(100 - min(max(window.usedPercent, 0), 100)) / 100) } } .frame(height: 4) @@ -197,11 +190,6 @@ struct SidebarQuotaFooter: View { .fixedSize(horizontal: false, vertical: true) } .frame(maxWidth: .infinity, alignment: .leading) - .padding(10) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color(nsColor: .controlBackgroundColor).opacity(0.55)) - ) .accessibilityElement(children: .combine) } diff --git a/Sources/TabItemView.swift b/Sources/TabItemView.swift index 908ff06f..54d8acef 100644 --- a/Sources/TabItemView.swift +++ b/Sources/TabItemView.swift @@ -1158,7 +1158,7 @@ struct TabItemView: View, Equatable { } private var explicitRailColor: Color? { - guard activeTabIndicatorStyle == .leftRail, + guard activeTabIndicatorStyle == .leftRail || isActive, let custom = resolvedCustomTabColor else { return nil } @@ -1749,6 +1749,7 @@ struct TabItemView: View, Equatable { private func promptNewWorktreeWorkspace() { guard let repoRoot = tab.worktreeFolderRepoRoot, let folderId = tab.worktreeFolderId else { return } + let parentWorkspaceId = tab.id let alert = NSAlert() alert.messageText = String(localized: "alert.newWorktreeWorkspace.title", defaultValue: "New Worktree Workspace") alert.informativeText = String(localized: "alert.newWorktreeWorkspace.message", defaultValue: "Enter the branch to create or open in a new worktree.") @@ -1812,6 +1813,7 @@ struct TabItemView: View, Equatable { path: path, branch: createdBranch, repoRoot: repoRoot, + parentWorkspaceId: parentWorkspaceId, select: true ) case .branchCheckedOut(let path): diff --git a/Sources/TabManager+SessionPersistence.swift b/Sources/TabManager+SessionPersistence.swift index 8664e376..cd367090 100644 --- a/Sources/TabManager+SessionPersistence.swift +++ b/Sources/TabManager+SessionPersistence.swift @@ -90,8 +90,17 @@ extension TabManager { newTabs.append(fallback) } - // Workspace instance IDs are intentionally regenerated on restore. Rebuild the runtime - // parent links from the persisted folder identity after every workspace exists. + // Workspace instance IDs are intentionally regenerated on restore. Remap saved + // parent identities only after every workspace exists; duplicate IDs are ambiguous. + let savedIdCounts = workspaceSnapshots.reduce(into: [UUID: Int]()) { counts, workspace in + if let id = workspace.id { counts[id, default: 0] += 1 } + } + var workspacesBySavedId: [UUID: Workspace] = [:] + for (saved, workspace) in zip(workspaceSnapshots, newTabs) { + if let id = saved.id, savedIdCounts[id] == 1 { + workspacesBySavedId[id] = workspace + } + } var worktreeFoldersById: [UUID: Workspace] = [:] for workspace in newTabs where workspace.isWorktreeFolder { guard let folderId = workspace.worktreeFolderId else { @@ -109,13 +118,35 @@ extension TabManager { } worktreeFoldersById[folderId] = workspace } - for workspace in newTabs where !workspace.isWorktreeFolder { - guard let folderId = workspace.worktreeFolderId, - let parent = worktreeFoldersById[folderId] else { - workspace.worktreeFolderId = nil + var proposedParents: [UUID: Workspace] = [:] + for (saved, workspace) in zip(workspaceSnapshots, newTabs) { + if let parentId = saved.worktreeParentWorkspaceId { + proposedParents[workspace.id] = workspacesBySavedId[parentId] + } else if saved.id == nil, !workspace.isWorktreeFolder, + let folderId = workspace.worktreeFolderId { + // Older snapshots only persisted folder membership. + proposedParents[workspace.id] = worktreeFoldersById[folderId] + } + } + for workspace in newTabs { + var visited: Set = [workspace.id] + var ancestor = proposedParents[workspace.id] + var hasCycle = false + while let current = ancestor { + guard visited.insert(current.id).inserted else { + hasCycle = true + break + } + ancestor = proposedParents[current.id] + } + guard !hasCycle, let parent = proposedParents[workspace.id] else { + if !workspace.isWorktreeFolder { workspace.worktreeFolderId = nil } continue } workspace.worktreeParentWorkspaceId = parent.id + if !workspace.isWorktreeFolder { + workspace.worktreeFolderId = parent.isWorktreeFolder ? parent.worktreeFolderId : nil + } } // Determine selection before mutating @Published properties. diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 7cf60e98..4e6cd2ca 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1932,9 +1932,15 @@ class TabManager: ObservableObject { branch: String?, repoRoot: String, layoutName: String? = nil, + parentWorkspaceId: UUID? = nil, select: Bool ) -> Workspace { - let parent = worktreeParentWorkspace(repoRoot: repoRoot) + let parent: Workspace? + if let parentWorkspaceId { + parent = tabs.first { $0.id == parentWorkspaceId } + } else { + parent = worktreeParentWorkspace(repoRoot: repoRoot) + } let insertionAnchor = parent.map { worktreeInsertionAnchor(for: $0) } if select, let parent, parent.isWorktreeFolderCollapsed { parent.isWorktreeFolderCollapsed = false @@ -1972,6 +1978,16 @@ class TabManager: ObservableObject { homeDirectoryForTildeExpansion: homeDirectory ) guard let repoKey else { return nil } + if let selectedWorkspace, + let selectedKey = SidebarBranchOrdering.canonicalDirectoryKey( + selectedWorkspace.isWorktreeFolder + ? selectedWorkspace.worktreeFolderRepoRoot + : selectedWorkspace.currentDirectory, + homeDirectoryForTildeExpansion: homeDirectory + ), + selectedKey == repoKey || selectedKey.hasPrefix(repoKey == "/" ? "/" : repoKey + "/") { + return selectedWorkspace + } if let folder = tabs.first(where: { guard $0.isWorktreeFolder, let folderRoot = $0.worktreeFolderRepoRoot else { return false } return SidebarBranchOrdering.canonicalDirectoryKey( @@ -1990,13 +2006,12 @@ class TabManager: ObservableObject { } private func detachWorktreeFolderRelationships(for workspace: Workspace) { - if workspace.isWorktreeFolder { - for child in worktreeChildren(of: workspace) { - child.worktreeParentWorkspaceId = nil - child.worktreeFolderId = nil - } - } else { - workspace.worktreeParentWorkspaceId = nil + for child in worktreeChildren(of: workspace) { + child.worktreeParentWorkspaceId = nil + child.worktreeFolderId = nil + } + workspace.worktreeParentWorkspaceId = nil + if !workspace.isWorktreeFolder { workspace.worktreeFolderId = nil } } @@ -2577,9 +2592,9 @@ class TabManager: ObservableObject { return } if tabs.count <= 1 { - // Last workspace in this window: close the window (Cmd+Shift+W behavior). - if let window { - window.performClose(nil) + // Closing the last workspace explicitly ends its sessions, unlike closing the window UI. + if let window, let app = AppDelegate.shared { + app.disposeMainWindow(window) } else { AppDelegate.shared?.closeMainWindowContainingTabId(workspace.id) } diff --git a/Sources/TerminalController+Worktree.swift b/Sources/TerminalController+Worktree.swift index d165bf66..520ac2ed 100644 --- a/Sources/TerminalController+Worktree.swift +++ b/Sources/TerminalController+Worktree.swift @@ -113,6 +113,7 @@ extension TerminalController { branch: entry.branch, repoRoot: repoRoot, layoutName: layoutName, + parentWorkspaceId: requiredParentWorkspaceId, select: shouldFocus ) return .ok([ @@ -148,6 +149,8 @@ extension TerminalController { } let focusRequested = v2Bool(params, "focus") ?? false + let requiredParentWorkspaceId = v2UUID(params, "required_parent_workspace_id") + let requiredParentDirectory = v2String(params, "required_parent_directory") return v2MainSync { guard let tabManager = AppDelegate.shared?.tabManagerFor(windowId: windowId) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) @@ -176,8 +179,8 @@ extension TerminalController { repoRoot: repoRoot, layoutName: nil, focusRequested: focusRequested, - requiredParentWorkspaceId: nil, - requiredParentDirectory: nil + requiredParentWorkspaceId: requiredParentWorkspaceId, + requiredParentDirectory: requiredParentDirectory ) } } diff --git a/Sources/WindowDragHandleView.swift b/Sources/WindowDragHandleView.swift index 47fcd8b2..697241f9 100644 --- a/Sources/WindowDragHandleView.swift +++ b/Sources/WindowDragHandleView.swift @@ -200,7 +200,7 @@ private var _windowDragHandleResolvingSiblingHitScopes = Set() /// `_windowDragHandleResolvingSiblingHitScopes`. private var _windowDragHandleTopHitReentrantScopes = Set() -/// Returns whether the titlebar drag handle should capture a hit at `point`. +/// Returns whether the titlebar drag handle should capture a hit at a local `point`. /// We only claim the hit when no sibling view already handles it, so interactive /// controls layered in the titlebar (e.g. proxy folder icon) keep their gestures. func windowDragHandleShouldCaptureHit( @@ -293,8 +293,8 @@ func windowDragHandleShouldCaptureHit( guard sibling !== dragHandleView else { continue } guard !sibling.isHidden, sibling.alphaValue > 0 else { continue } - let pointInSibling = dragHandleView.convert(point, to: sibling) - if let hitView = sibling.hitTest(pointInSibling) { + let pointInSiblingParent = dragHandleView.convert(point, to: sibling.superview) + if let hitView = sibling.hitTest(pointInSiblingParent) { let passiveHostHit = windowDragHandleShouldTreatTopHitAsPassiveHost(hitView) if passiveHostHit { #if DEBUG @@ -328,8 +328,8 @@ func windowDragHandleShouldCaptureHit( // bail-out rather than a real competing view, so we must not use it to // block capture. _windowDragHandleTopHitReentrantScopes.remove(resolutionScope) - let pointInSuperview = dragHandleView.convert(point, to: superview) - let topHit = superview.hitTest(pointInSuperview) + let pointInSuperviewParent = dragHandleView.convert(point, to: superview.superview) + let topHit = superview.hitTest(pointInSuperviewParent) let topHitResolutionReentered = _windowDragHandleTopHitReentrantScopes.remove(resolutionScope) != nil if !topHitResolutionReentered, @@ -405,7 +405,7 @@ struct WindowDragHandleView: NSViewRepresentable { return nil } let shouldCapture = windowDragHandleShouldCaptureHit( - point, + convert(point, from: superview), in: self, eventType: currentEvent?.type, eventWindow: currentEvent?.window diff --git a/Sources/WindowPaneChromePortal.swift b/Sources/WindowPaneChromePortal.swift index 2a71d4bd..984f9fd0 100644 --- a/Sources/WindowPaneChromePortal.swift +++ b/Sources/WindowPaneChromePortal.swift @@ -511,13 +511,30 @@ private final class PaneChromePortalHostView: NSView { @MainActor @available(macOS 26.0, *) -private final class NativePaneTabBarView: NSView { +class PaneChromeDragBackgroundView: NSView { + override var mouseDownCanMoveWindow: Bool { false } + + override func mouseDown(with event: NSEvent) { + guard let window, !isWindowDragSuppressed(window: window) else { return } + if event.clickCount >= 2 { + performStandardTitlebarDoubleClick(window: window) + } else { + withTemporaryWindowMovableEnabled(window: window) { + window.performDrag(with: event) + } + } + } +} + +@MainActor +@available(macOS 26.0, *) +private final class NativePaneTabBarView: PaneChromeDragBackgroundView { private let scrollView = NSScrollView(frame: .zero) // Unflipped on purpose: a flipped document view mirrors the glass pills' // built-in shadow upward (layer geometry flip flips shadowOffset), while // the control capsules in the unflipped host cast theirs downward. Layout // doesn't care — every child spans the full row height at y = 0. - private let documentView = NSView(frame: .zero) + private let documentView = PaneChromeDragBackgroundView(frame: .zero) private var pillViews: [TabID: NativeGlassTabPillView] = [:] private var descriptor: BonsplitPaneChromeDescriptor? /// Safari-style "+" after the last pill; scrolls with the tabs. Bare glyph, diff --git a/Sources/WindowSwizzles.swift b/Sources/WindowSwizzles.swift index e6803e6c..59176cd1 100644 --- a/Sources/WindowSwizzles.swift +++ b/Sources/WindowSwizzles.swift @@ -77,6 +77,11 @@ extension NSApplication { // Widened from `private extension` to `extension`: AppDelegate.installWindowResponderSwizzles() // (in AppDelegate.swift) references these @objc methods via #selector(...) for method swizzling. Refs #95. extension NSWindow { + @objc func programa_close() { + if AppDelegate.shared?.preserveMainWindowOnClose(self) == true { return } + programa_close() + } + @objc func programa_makeFirstResponder(_ responder: NSResponder?) -> Bool { if programaIsWindowFirstResponderBypassActive() { #if DEBUG @@ -287,11 +292,9 @@ extension NSWindow { ) #endif if event.clickCount >= 2 { - // Match the WindowDragHandleView regions: titlebar-area - // double-click opens a tab, not the standard zoom/minimize. - _ = AppDelegate.shared?.tabManager?.addTab() + let action = performStandardTitlebarDoubleClick(window: self) #if DEBUG - dlog("titlebar.chromeDrag doubleClick action=addTab") + dlog("titlebar.chromeDrag doubleClick action=\(String(describing: action))") #endif } else { withTemporaryWindowMovableEnabled(window: self) { diff --git a/Sources/Workspace+Persistence.swift b/Sources/Workspace+Persistence.swift index d5fb6420..751fa386 100644 --- a/Sources/Workspace+Persistence.swift +++ b/Sources/Workspace+Persistence.swift @@ -74,7 +74,9 @@ extension Workspace { worktreeFolderRepoRoot: worktreeFolderRepoRoot, isWorktreeFolder: isWorktreeFolder, isWorktreeFolderCollapsed: isWorktreeFolderCollapsed, - worktreeBranch: worktreeBranch + worktreeBranch: worktreeBranch, + id: id, + worktreeParentWorkspaceId: worktreeParentWorkspaceId ) } @@ -529,7 +531,12 @@ extension Workspace { // (`TerminalSurface.pendingReviveSeed` / `seedRevivedScrollbackIfPending`) // instead of the old temp-file + shell-rc `cat` mechanism -- // see `SessionFreshSpawnScrollbackSeed`'s doc comment. - let preparedSeedText = SessionFreshSpawnScrollbackSeed.preparedText(for: scrollbackText) + let recoveryNotice = String( + localized: "session_recovery.freshShell.notice", + defaultValue: "A new shell was started because the previous process could not be reconnected." + ) + let preparedSeedText = (SessionFreshSpawnScrollbackSeed.preparedText(for: scrollbackText) ?? "") + + "\r\n" + recoveryNotice + "\r\n" guard let terminalPanel = newTerminalSurface( inPane: paneId, focus: false, diff --git a/docs/keyboard-shortcuts.md b/docs/keyboard-shortcuts.md index dc6adf31..5a116ce3 100644 --- a/docs/keyboard-shortcuts.md +++ b/docs/keyboard-shortcuts.md @@ -91,11 +91,18 @@ All, Needs input, and Failed filters or search by workspace/agent name to find a | Shortcut | Action | |----------|--------| | ⌘ ⇧ N | New window | +| ⌃ ⌘ W | Hide window, keeping its sessions running | | ⌘ ⇧ P | Command palette | | ⌘ , | Settings | | ⌘ ⇧ , | Reload configuration | | ⌘ Q | Quit | +Closing a window with its red button or Close Window keeps its workspaces and terminal +processes running. Click Programa in the Dock to reopen it. When no main window is +visible, New Window and New Workspace reopen the most recently closed window first. +Close Surface and Close Workspace still end the selected session; quitting the app +uses session recovery on the next launch rather than keeping every process running. + ## Review | Shortcut | Action | diff --git a/docs/testing-layout.md b/docs/testing-layout.md index a9067241..3cdc3731 100644 --- a/docs/testing-layout.md +++ b/docs/testing-layout.md @@ -7,7 +7,7 @@ each other, and `tests_v2` is not "the second attempt at `tests`". |---|---|---|---| | `tests/` | shell + python | no | `workflow-guard-tests`, CLI steps in `ci.yml` | | `tests_v2/` | python | **yes** | `socket-integration-tests`, lag/perf jobs | -| `programaTests/` | Swift (XCTest) | no | `unit-tests` (`programa-unit` scheme) | +| `programaTests/` | Swift (XCTest) | launches its app test host | `unit-tests` (`programa-unit` scheme) | | `programaUITests/` | Swift (XCUITest) | launches its own | `ui-regressions` | ## What `_v2` means @@ -30,8 +30,8 @@ socket. Everything here talks to a live instance, whether through `cmux.py` or by speaking JSON-RPC directly. The lag/perf/CPU harnesses live here for that reason. See `docs/cpu-harness.md` for the CPU measurement harness specifically. -**`programaTests/`** — pure logic that can be exercised without launching -anything: policy types, decision functions, snapshot encoding, layout maths. +**`programaTests/`** — policy types, decision functions, snapshot encoding, +layout maths, and AppKit integration through the configured app test host. Cheapest and fastest, so prefer it when a behaviour can be reached this way. When it cannot, add a small seam so it can, rather than reaching for a socket test. See the test-quality policy in `CLAUDE.md`. @@ -42,7 +42,16 @@ delivery and AppKit: clicks, drags, menus, focus. ## Running them Per `CLAUDE.md`, tests are **not run locally** — they run on CI or the VM. -`xcodebuild -scheme programa-unit` is the one safe local exception because it -launches no app. `tests_v2` in particular will attach to whatever socket it +`programa-unit` is the local unit-test exception, but it is **app-hosted**: +the project's default `TEST_HOST` points to the untagged Debug app. Use tagged +bundle-name/identifier overrides and isolated derived data with `build-for-testing`, +then inspect a copy of its generated `.xctestrun` before `test-without-building`. +Its `TestHostBundleIdentifier` must match the built tagged app; Xcode can leave +the original identifier in that field. If moving the copy out of `Build/Products`, +resolve its `__TESTROOT__` paths to absolute product paths. Overriding `TEST_HOST` +to the app copied by `reload.sh` fails Xcode's scheme build-graph validation. +Do not run the default untagged test host. XCTest disables WAL/escrow, but the +host can still create windows, start shells, and mutate its own preferences. +`tests_v2` in particular will attach to whatever socket it finds, which is why running it locally risks driving your real Programa instance rather than a build under test. diff --git a/programaTests/AppDelegateShortcutRoutingTests.swift b/programaTests/AppDelegateShortcutRoutingTests.swift index 8a67b467..1a502e59 100644 --- a/programaTests/AppDelegateShortcutRoutingTests.swift +++ b/programaTests/AppDelegateShortcutRoutingTests.swift @@ -95,7 +95,6 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { override func tearDown() { KeyboardShortcutSettings.settingsFileStore = originalSettingsFileStore AppDelegate.shared?.shortcutLayoutCharacterProvider = KeyboardLayout.character(forKeyCode:modifierFlags:) - AppDelegate.shared?.debugCloseMainWindowConfirmationHandler = nil AppDelegate.shared?.debugCreateMainWindowSourceIsNativeFullScreenOverride = nil AppDelegate.shared?.dismissNotificationsPopoverIfShown() RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) @@ -353,6 +352,71 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { )) } +#if DEBUG + func testFailedQuitSaveKeepsSessionsRunningAndSuccessfulRetryCanQuit() throws { + let appDelegate = try XCTUnwrap(AppDelegate.shared) + closeAllMainWindows() + let windowId = UUID() + let window = makeUnregisteredMainWindow(windowId: windowId) + let manager = TabManager() + let warningWasEnabled = QuitWarningSettings.isEnabled() + QuitWarningSettings.setEnabled(false) + defer { + appDelegate.debugResetTerminationForTesting() + appDelegate.debugSessionSnapshotSaverForTesting = nil + appDelegate.debugQuitSaveFailureAlertForTesting = nil + QuitWarningSettings.setEnabled(warningWasEnabled) + _ = appDelegate.closeMainWindow(windowId: windowId) + manager.teardownForWindowClose() + } + appDelegate.registerMainWindow( + window, windowId: windowId, tabManager: manager, + sidebarState: SidebarState(), sidebarSelectionState: SidebarSelectionState() + ) + + var saveAttempts = 0 + var failureAlerts = 0 + appDelegate.debugSessionSnapshotSaverForTesting = { _ in + saveAttempts += 1 + return saveAttempts > 1 + } + appDelegate.debugQuitSaveFailureAlertForTesting = { failureAlerts += 1 } + + XCTAssertEqual(appDelegate.applicationShouldTerminate(NSApp), .terminateCancel) + XCTAssertEqual(saveAttempts, 1) + XCTAssertEqual(failureAlerts, 1) + XCTAssertFalse(SessionMachineryGate.isApplicationTerminating) + XCTAssertTrue(appDelegate.tabManagerFor(windowId: windowId) === manager) + XCTAssertFalse(manager.selectedWorkspace?.panels.isEmpty ?? true) + + XCTAssertEqual(appDelegate.applicationShouldTerminate(NSApp), .terminateNow) + XCTAssertEqual(saveAttempts, 2) + XCTAssertTrue(SessionMachineryGate.isApplicationTerminating) + } + + func testWindowlessQuitDoesNotRequireAStoredSnapshot() throws { + let appDelegate = try XCTUnwrap(AppDelegate.shared) + closeAllMainWindows() + let warningWasEnabled = QuitWarningSettings.isEnabled() + QuitWarningSettings.setEnabled(false) + defer { + appDelegate.debugResetTerminationForTesting() + appDelegate.debugSessionSnapshotSaverForTesting = nil + appDelegate.debugQuitSaveFailureAlertForTesting = nil + QuitWarningSettings.setEnabled(warningWasEnabled) + } + var failureAlerts = 0 + appDelegate.debugQuitSaveFailureAlertForTesting = { failureAlerts += 1 } + appDelegate.debugSessionSnapshotSaverForTesting = { _ in + XCTFail("An empty app must not attempt a snapshot write") + return false + } + + XCTAssertEqual(appDelegate.applicationShouldTerminate(NSApp), .terminateNow) + XCTAssertEqual(failureAlerts, 0) + } +#endif + func testConcurrentDuplicateRequestGenerationsCannotOverwriteOrDeleteEachOther() throws { let rootDirectory = FileManager.default.temporaryDirectory.appendingPathComponent( "programa-single-instance-ownership-\(UUID().uuidString)", @@ -522,6 +586,98 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { } } + func testFailedQuitCanRevokeOnlyItsOwnAcknowledgmentGeneration() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) + defer { try? FileManager.default.removeItem(at: directory) } + let target = ProgramaSingleInstanceProcessKey( + startSeconds: 1_000, startMicroseconds: 1, processIdentifier: 100 + ) + let request = AppDelegate.SingleInstanceShutdownRequest( + target: target, + requester: ProgramaSingleInstanceProcessKey( + startSeconds: 1_001, startMicroseconds: 1, processIdentifier: 200 + ), + createdAtUnixSeconds: 10_000 + ) + let requestURL = try XCTUnwrap(AppDelegate.writeDuplicateRequestForTesting( + rootDirectory: directory, request: request + )) + let acknowledgmentURL = AppDelegate.duplicateAcknowledgmentURLForTesting( + rootDirectory: directory, target: target, generation: request.generation + ) + let acknowledgment = AppDelegate.SingleInstanceShutdownAcknowledgment( + acceptedGeneration: request.generation, target: target, createdAtUnixSeconds: 10_001 + ) + try JSONEncoder().encode(acknowledgment).write(to: acknowledgmentURL, options: .atomic) + + XCTAssertFalse(AppDelegate.removeExactAcknowledgment( + target: target, acceptedGeneration: UUID(), url: acknowledgmentURL + )) + XCTAssertTrue(FileManager.default.fileExists(atPath: acknowledgmentURL.path)) + XCTAssertTrue(AppDelegate.removeExactAcknowledgment( + target: target, acceptedGeneration: request.generation, url: acknowledgmentURL + )) + XCTAssertFalse(FileManager.default.fileExists(atPath: acknowledgmentURL.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: requestURL.path)) + XCTAssertFalse(AppDelegate.hasValidDuplicateAcknowledgmentForTesting( + rootDirectory: directory, request: request, now: 10_002 + )) + XCTAssertEqual(AppDelegate.duplicateFallbackActionForTesting( + hasValidTargetAcknowledgment: false, + requestGenerationIsPending: true, + processIdentityMatches: true, + isTerminated: false, + response: nil + ), .prompt) + } + + func testStartupHandoffWaitsForEveryAuthenticatedOlderProcessToExit() { + let first = ProgramaSingleInstanceProcessKey( + startSeconds: 1_000, startMicroseconds: 1, processIdentifier: 100 + ) + let second = ProgramaSingleInstanceProcessKey( + startSeconds: 1_001, startMicroseconds: 1, processIdentifier: 200 + ) + var firstLive = true + var secondLive = true + var readyCount = 0 + let handoff = StartupSessionHandoff( + olderProcess: { firstLive ? first : (secondLive ? second : nil) }, + isLive: { key in key == first ? firstLive : secondLive }, + onReady: { readyCount += 1 } + ) + + XCTAssertTrue(handoff.shouldDefer()) + firstLive = false + handoff.poll() + XCTAssertTrue(handoff.isWaiting) + XCTAssertEqual(readyCount, 0) + secondLive = false + handoff.poll() + XCTAssertFalse(handoff.isWaiting) + XCTAssertFalse(handoff.shouldDefer()) + XCTAssertEqual(readyCount, 1) + } + + func testStartupHandoffDoesNotReadBeforeInitialArbitration() { + var olderLookups = 0 + var readyCount = 0 + let handoff = StartupSessionHandoff( + hasCompletedInitialArbitration: false, + olderProcess: { olderLookups += 1; return nil }, + isLive: { _ in false }, + onReady: { readyCount += 1 } + ) + + XCTAssertTrue(handoff.shouldDefer()) + XCTAssertEqual(olderLookups, 0) + handoff.initialArbitrationCompleted() + XCTAssertFalse(handoff.shouldDefer()) + XCTAssertEqual(olderLookups, 1) + XCTAssertEqual(readyCount, 1) + } + func testDuplicateStateRecoversFromMoreThanScanLimitRecognizedStaleEntries() throws { let rootDirectory = FileManager.default.temporaryDirectory.appendingPathComponent( "programa-single-instance-stale-cap-\(UUID().uuidString)", @@ -806,7 +962,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { request: request, now: 10_003 ), - "The target's durable responsive state must suppress fallback for existing and later request generations" + "The target's durable responsive state must defer force-close for existing and later request generations" ) XCTAssertEqual(AppDelegate.duplicateFallbackActionForTesting( hasValidTargetAcknowledgment: true, @@ -814,7 +970,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { processIdentityMatches: true, isTerminated: false, response: nil - ), .skip) + ), .waitForAcknowledgedExit) } } @@ -890,7 +1046,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { processIdentityMatches: true, isTerminated: false, response: .forceClose - ), .skip) + ), .waitForAcknowledgedExit) XCTAssertEqual(AppDelegate.duplicateFallbackActionForTesting( hasValidTargetAcknowledgment: false, requestGenerationIsPending: false, @@ -1951,11 +2107,8 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { // The app's own default WindowGroup window would otherwise always be a live, // eligible fallback target and defeat this test's "no live window" precondition. - // Closing it would normally show a confirmation sheet (it may host a running - // process), so auto-confirm for the duration of this cleanup. - appDelegate.debugCloseMainWindowConfirmationHandler = { _ in true } + // Explicit disposal removes it rather than preserving a hidden session. closeAllMainWindows() - appDelegate.debugCloseMainWindowConfirmationHandler = nil RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) let orphanWindowId = UUID() @@ -2011,11 +2164,8 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { // The app's own default WindowGroup window would otherwise always be a live, // eligible fallback target and defeat this test's "no live window" precondition. - // Closing it would normally show a confirmation sheet (it may host a running - // process), so auto-confirm for the duration of this cleanup. - appDelegate.debugCloseMainWindowConfirmationHandler = { _ in true } + // Explicit disposal removes it rather than preserving a hidden session. closeAllMainWindows() - appDelegate.debugCloseMainWindowConfirmationHandler = nil RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) let existingWindowIds = mainWindowIds() @@ -2539,7 +2689,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { ) } - func testCmdCtrlWPromptsBeforeClosingWindow() { + func testCmdCtrlWHidesWindowAndPreservesItsSessions() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") return @@ -2553,11 +2703,9 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { return } - var promptedWindow: NSWindow? - appDelegate.debugCloseMainWindowConfirmationHandler = { candidate in - promptedWindow = candidate - return false - } + let manager = appDelegate.tabManagerFor(windowId: windowId) + let workspace = manager?.selectedWorkspace + let panelIds = workspace.map { Set($0.panels.keys) } guard let event = makeKeyDownEvent( key: "w", @@ -2575,53 +2723,111 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTFail("debugHandleCustomShortcut is only available in DEBUG") #endif - waitUntil(description: "close confirmation handler to be invoked for the target window") { promptedWindow != nil } - - XCTAssertTrue(promptedWindow === targetWindow, "Cmd+Ctrl+W should prompt for the target main window") - XCTAssertNotNil(self.window(withId: windowId), "Cancelling the confirmation should keep the window open") + XCTAssertFalse(targetWindow.isVisible) + XCTAssertTrue(appDelegate.tabManagerFor(windowId: windowId) === manager) + XCTAssertTrue(manager?.selectedWorkspace === workspace) + XCTAssertEqual(workspace.map { Set($0.panels.keys) }, panelIds) + XCTAssertTrue(appDelegate.reopenMostRecentlyHiddenMainWindow(onlyIfNoVisibleMainWindows: false)) + XCTAssertTrue(targetWindow.isVisible) + XCTAssertTrue(appDelegate.tabManagerFor(windowId: windowId) === manager) } - func testCmdCtrlWClosesWindowAfterConfirmation() { + func testNativeCloseReopensMostRecentlyHiddenWindowWithoutAddingWorkspace() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") return } let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } guard let targetWindow = window(withId: windowId) else { XCTFail("Expected test window") return } - appDelegate.debugCloseMainWindowConfirmationHandler = { _ in true } - - guard let event = makeKeyDownEvent( - key: "w", - modifiers: [.command, .control], - keyCode: 13, - windowNumber: targetWindow.windowNumber - ) else { - XCTFail("Failed to construct Cmd+Ctrl+W event") + let olderWindowId = appDelegate.createMainWindow() + defer { closeWindow(withId: olderWindowId) } + guard let olderWindow = window(withId: olderWindowId) else { + XCTFail("Expected second test window") return } + olderWindow.close() + let manager = appDelegate.tabManagerFor(windowId: windowId) + let workspaceIds = manager?.tabs.map(\.id) + targetWindow.performClose(nil) + XCTAssertFalse(targetWindow.isVisible) + XCTAssertTrue(appDelegate.reopenMostRecentlyHiddenMainWindow(onlyIfNoVisibleMainWindows: false)) + XCTAssertTrue(targetWindow.isVisible) + XCTAssertFalse(olderWindow.isVisible, "Reopen must choose the last hidden window") + XCTAssertEqual(manager?.tabs.map(\.id), workspaceIds) + XCTAssertTrue(appDelegate.tabManagerFor(windowId: windowId) === manager) + } -#if DEBUG + func testHiddenPrimaryWindowRetainsItsWindowAndWorkspaceUntilExplicitDisposal() throws { + let appDelegate = try XCTUnwrap(AppDelegate.shared) + AppDelegate.installWindowResponderSwizzlesForTesting() + let windowId = UUID() + defer { _ = appDelegate.closeMainWindow(windowId: windowId) } + let manager = TabManager() + let workspace = try XCTUnwrap(manager.selectedWorkspace) + weak var retainedWindow: NSWindow? + autoreleasepool { + let primaryWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable], backing: .buffered, defer: false + ) + primaryWindow.isReleasedWhenClosed = false + appDelegate.registerMainWindow( + primaryWindow, windowId: windowId, tabManager: manager, + sidebarState: SidebarState(), sidebarSelectionState: SidebarSelectionState() + ) + retainedWindow = primaryWindow + primaryWindow.close() + } + let window = try XCTUnwrap(retainedWindow) + XCTAssertTrue(appDelegate.tabManagerFor(windowId: windowId) === manager) + XCTAssertFalse(workspace.panels.isEmpty) + XCTAssertTrue(appDelegate.closeMainWindow(windowId: windowId)) + XCTAssertNil(appDelegate.tabManagerFor(windowId: windowId)) + XCTAssertTrue(workspace.panels.isEmpty) + XCTAssertFalse(window.isVisible) + } + + func testDockNewWindowAndNewWorkspaceReopenHiddenSession() throws { + let appDelegate = try XCTUnwrap(AppDelegate.shared) + closeAllMainWindows() + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + let targetWindow = try XCTUnwrap(window(withId: windowId)) + let manager = try XCTUnwrap(appDelegate.tabManagerFor(windowId: windowId)) + let workspaceIds = manager.tabs.map(\.id) + targetWindow.close() + XCTAssertFalse(appDelegate.applicationShouldHandleReopen(NSApp, hasVisibleWindows: false)) + XCTAssertTrue(targetWindow.isVisible) + + targetWindow.close() + XCTAssertFalse(appDelegate.applicationShouldHandleReopen(NSApp, hasVisibleWindows: true), + "Visible auxiliary windows must not prevent reopening the main session") + XCTAssertTrue(targetWindow.isVisible) + + targetWindow.close() + appDelegate.openNewMainWindow(nil) + XCTAssertTrue(targetWindow.isVisible) + XCTAssertEqual(mainWindowIds(), Set([windowId])) + + targetWindow.close() + let event = try XCTUnwrap(makeKeyDownEvent( + key: "n", modifiers: [.command], keyCode: 45, + windowNumber: targetWindow.windowNumber + )) + #if DEBUG XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) -#else + #else XCTFail("debugHandleCustomShortcut is only available in DEBUG") -#endif - - waitUntil(description: "confirmed Cmd+Ctrl+W to close the target window") { !targetWindow.isVisible } - - // NOTE: `self.window(withId:)` (an `NSApp.windows` lookup) does not reliably - // reflect a just-closed window in this test host process -- confirmed by - // instrumenting `NSWindow.close()` during investigation: `targetWindow.isVisible` - // flips to `false` immediately and `NSWindow.willCloseNotification` fires - // (unregistering the window's `MainWindowContext`), yet `NSApp.windows` can still - // report the instance as present. Assert on the two signals that actually reflect - // whether the close took effect. - XCTAssertFalse(targetWindow.isVisible, "Confirming Cmd+Ctrl+W should close the window") - XCTAssertNil(appDelegate.tabManagerFor(windowId: windowId), "Confirmed close should unregister the window's context") + #endif + XCTAssertTrue(targetWindow.isVisible) + XCTAssertEqual(manager.tabs.map(\.id), workspaceIds) + XCTAssertEqual(mainWindowIds(), Set([windowId])) } func testClosingMainWindowTearsDownEveryOwnedWorkspaceAndBrowserElementRef() throws { @@ -2765,8 +2971,8 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: initialWindow) } } - initialWindow.close() - replacementWindow?.close() + appDelegate.disposeMainWindow(initialWindow) + if let replacementWindow { appDelegate.disposeMainWindow(replacementWindow) } initialManager.teardownForWindowClose() store.manager.teardownForWindowClose() } @@ -2856,9 +3062,9 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { } else if appDelegate.tabManagerFor(windowId: windowId) != nil { NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: replacementWindow) } - firstWindow.close() - replacementWindow.close() - finalWindow?.close() + appDelegate.disposeMainWindow(firstWindow) + appDelegate.disposeMainWindow(replacementWindow) + if let finalWindow { appDelegate.disposeMainWindow(finalWindow) } manager.teardownForWindowClose() finalManager?.teardownForWindowClose() } @@ -2944,8 +3150,8 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { if appDelegate.tabManagerFor(windowId: displacedWindowId) != nil { NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: occupiedWindow) } - firstWindow.close() - occupiedWindow.close() + appDelegate.disposeMainWindow(firstWindow) + appDelegate.disposeMainWindow(occupiedWindow) firstManager.teardownForWindowClose() displacedManager.teardownForWindowClose() } @@ -3008,8 +3214,6 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { return } - // Auto-confirm window close to avoid a modal dialog that blocks the RunLoop. - appDelegate.debugCloseMainWindowConfirmationHandler = { _ in true } let windowId = appDelegate.createMainWindow() defer { closeWindow(withId: windowId) } @@ -6329,13 +6533,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { } private func mainWindowIds() -> Set { - Set(NSApp.windows.compactMap { window in - guard let raw = window.identifier?.rawValue, - raw.hasPrefix("cmux.main.") else { - return nil - } - return UUID(uuidString: String(raw.dropFirst("cmux.main.".count))) - }) + Set(AppDelegate.shared?.listMainWindowSummaries().map(\.windowId) ?? []) } /// Regression test for the ghostty IO-thread callback-context teardown race. @@ -6380,14 +6578,6 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { return } - let hadCustomConfirmationHandler = appDelegate.debugCloseMainWindowConfirmationHandler != nil - appDelegate.debugCloseMainWindowConfirmationHandler = { _ in true } - defer { - if !hadCustomConfirmationHandler { - appDelegate.debugCloseMainWindowConfirmationHandler = nil - } - } - for iteration in 0..<28 { let windowId = appDelegate.createMainWindow() guard window(withId: windowId) != nil else { @@ -6438,23 +6628,12 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { return nil } - /// Cleanup-only window close used throughout this file's `defer` blocks. Closing a - /// window with a non-idle workspace normally raises a confirmation sheet; tests that - /// don't care about that flow (the overwhelming majority, which only want the window - /// gone) would otherwise leave it dangling for the rest of the process, polluting - /// later tests that assert on the full set of live main windows. Auto-confirm here - /// unless the test already installed its own handler to specifically exercise that - /// confirmation behavior (e.g. `testCmdCtrlWClosesWindowAfterConfirmation`). + /// Explicit disposal keeps test cleanup destructive after ordinary window close + /// became a session-preserving hide operation. private func closeWindow(withId windowId: UUID) { - guard let window = window(withId: windowId) else { return } - let hadCustomConfirmationHandler = AppDelegate.shared?.debugCloseMainWindowConfirmationHandler != nil - if !hadCustomConfirmationHandler { - AppDelegate.shared?.debugCloseMainWindowConfirmationHandler = { _ in true } - } - window.performClose(nil) - if !hadCustomConfirmationHandler { - AppDelegate.shared?.debugCloseMainWindowConfirmationHandler = nil - } + // NSApp.windows can retain already-closed windows with the same identifier. + // Resolve the registered context so cleanup always disposes its live owner. + guard AppDelegate.shared?.closeMainWindow(windowId: windowId) == true else { return } RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) } diff --git a/programaTests/RendererRealizationPlannerTests.swift b/programaTests/RendererRealizationPlannerTests.swift index 70499c9d..02363df8 100644 --- a/programaTests/RendererRealizationPlannerTests.swift +++ b/programaTests/RendererRealizationPlannerTests.swift @@ -55,7 +55,7 @@ final class RendererRealizationPlannerTests: XCTestCase { let old = UUID() let selected = RendererRealizationPlanner.selectedSurfaceIds( inputs: [ - input(recent, lastVisibleAt: now - 2), + input(recent, visible: true, lastVisibleAt: now - 2), input(warm, lastVisibleAt: now - 100), input(old, lastVisibleAt: now - 200), ], @@ -82,12 +82,25 @@ final class RendererRealizationPlannerTests: XCTestCase { let higher = UUID(uuidString: "00000000-0000-0000-0000-000000000002")! let selected = RendererRealizationPlanner.selectedSurfaceIds( inputs: [ + input(UUID(), visible: true, lastVisibleAt: 1_000), input(higher, lastVisibleAt: 0), input(lower, lastVisibleAt: 0), ], - settings: settings(idle: 5, warm: 1), + settings: settings(idle: 5, warm: 2), now: 1_000 ) XCTAssertEqual(selected, [higher]) } + + func testAllHiddenSurfacesReleaseGraphicsAfterIdleThreshold() { + let old = UUID() + let recent = UUID() + let inputs = [input(old, lastVisibleAt: 0), input(recent, lastVisibleAt: 990)] + XCTAssertEqual(RendererRealizationPlanner.selectedSurfaceIds( + inputs: inputs, settings: settings(), now: 1_000 + ), [old]) + XCTAssertEqual(RendererRealizationPlanner.selectedSurfaceIds( + inputs: inputs, settings: settings(), now: 1_021 + ), [old, recent]) + } } diff --git a/programaTests/SessionAutosaveCoordinatorTests.swift b/programaTests/SessionAutosaveCoordinatorTests.swift index 9b2e80d1..8af4d626 100644 --- a/programaTests/SessionAutosaveCoordinatorTests.swift +++ b/programaTests/SessionAutosaveCoordinatorTests.swift @@ -28,9 +28,9 @@ final class SessionAutosaveCoordinatorTests: XCTestCase { snapshotCallCount += 1 return Self.fakeSnapshot }, - saveSnapshot: { _, _ in + saveSnapshot: { _, _, completion in saveCallCount += 1 - return true + completion(true) }, isTerminating: { false }, isRunningUnderXCTest: { true } @@ -57,9 +57,9 @@ final class SessionAutosaveCoordinatorTests: XCTestCase { let coordinator = SessionAutosaveCoordinator( sessionPersistenceQueue: DispatchQueue(label: "test.session-autosave.deferred-retry"), snapshotProvider: { _ in Self.fakeSnapshot }, - saveSnapshot: { _, _ in + saveSnapshot: { _, _, completion in saveCallCount += 1 - return true + completion(true) }, isTerminating: { false }, isRunningUnderXCTest: { true } @@ -90,9 +90,9 @@ final class SessionAutosaveCoordinatorTests: XCTestCase { let coordinator = SessionAutosaveCoordinator( sessionPersistenceQueue: DispatchQueue(label: "test.session-autosave.fingerprint"), snapshotProvider: { _ in Self.fakeSnapshot }, - saveSnapshot: { _, _ in + saveSnapshot: { _, _, completion in saveCallCount += 1 - return true + completion(true) }, isTerminating: { false }, isRunningUnderXCTest: { true } @@ -109,6 +109,106 @@ final class SessionAutosaveCoordinatorTests: XCTestCase { ) } + @MainActor + func testPendingDiskWriteSuppressesOverlappingSnapshotBuilds() { + var snapshotCallCount = 0 + var completion: ((Bool) -> Void)? + let coordinator = SessionAutosaveCoordinator( + sessionPersistenceQueue: DispatchQueue(label: "test.session-autosave.pending"), + snapshotProvider: { _ in + snapshotCallCount += 1 + return Self.fakeSnapshot + }, + saveSnapshot: { _, _, callback in completion = callback }, + isTerminating: { false }, + isRunningUnderXCTest: { true } + ) + + coordinator.runSessionAutosaveTick(source: "first") + coordinator.runSessionAutosaveTick(source: "pending") + XCTAssertEqual(snapshotCallCount, 1) + completion?(true) + coordinator.runSessionAutosaveTick(source: "completed") + XCTAssertEqual(snapshotCallCount, 2) + } + + @MainActor + func testPromptSaveDuringDiskWriteRunsAfterCompletion() { + var completions: [(Bool) -> Void] = [] + var currentSnapshot = Self.fakeSnapshot + let followUp = expectation(description: "changed snapshot saved after in-flight write") + let coordinator = SessionAutosaveCoordinator( + sessionPersistenceQueue: DispatchQueue(label: "test.session-autosave.prompt-pending"), + snapshotProvider: { _ in currentSnapshot }, + saveSnapshot: { _, snapshot, completion in + completions.append(completion) + if completions.count == 2 { + XCTAssertEqual(snapshot?.cleanShutdown, true) + followUp.fulfill() + } + }, + isTerminating: { false }, + isRunningUnderXCTest: { true } + ) + coordinator.runSessionAutosaveTick(source: "first") + currentSnapshot.cleanShutdown = true + coordinator.requestPromptSave(source: "changed", after: 0) + let promptFired = expectation(description: "prompt callback encountered pending write") + DispatchQueue.main.async { promptFired.fulfill() } + wait(for: [promptFired], timeout: 2) + XCTAssertEqual(completions.count, 1) + completions[0](true) + wait(for: [followUp], timeout: 2) + XCTAssertEqual(completions.count, 2) + coordinator.stopSessionAutosaveTimer() + } + + @MainActor + func testFailedDiskWriteRetriesUnchangedSnapshot() { + var completions: [(Bool) -> Void] = [] + let coordinator = SessionAutosaveCoordinator( + sessionPersistenceQueue: DispatchQueue(label: "test.session-autosave.failed-write"), + snapshotProvider: { _ in Self.fakeSnapshot }, + saveSnapshot: { _, _, completion in completions.append(completion) }, + isTerminating: { false }, + isRunningUnderXCTest: { true } + ) + + coordinator.runSessionAutosaveTick(source: "first") + XCTAssertEqual(completions.count, 1) + completions[0](false) + coordinator.runSessionAutosaveTick(source: "retry") + XCTAssertEqual(completions.count, 2, "failed writes must not advance the saved fingerprint") + completions[1](true) + coordinator.runSessionAutosaveTick(source: "saved") + XCTAssertEqual(completions.count, 2) + coordinator.stopSessionAutosaveTimer() + } + + @MainActor + func testStopInvalidatesLateWriteCompletion() { + var completions: [(Bool) -> Void] = [] + let coordinator = SessionAutosaveCoordinator( + sessionPersistenceQueue: DispatchQueue(label: "test.session-autosave.stopped-write"), + snapshotProvider: { _ in Self.fakeSnapshot }, + saveSnapshot: { _, _, completion in completions.append(completion) }, + isTerminating: { false }, + isRunningUnderXCTest: { true } + ) + + coordinator.runSessionAutosaveTick(source: "beforeStop") + coordinator.stopSessionAutosaveTimer() + coordinator.runSessionAutosaveTick(source: "afterStop") + XCTAssertEqual(completions.count, 2) + completions[0](true) + coordinator.runSessionAutosaveTick(source: "stillPending") + XCTAssertEqual(completions.count, 2, "stale completion must not clear the current write") + completions[1](false) + coordinator.runSessionAutosaveTick(source: "retry") + XCTAssertEqual(completions.count, 3, "stale success must not record a saved fingerprint") + coordinator.stopSessionAutosaveTimer() + } + @MainActor func testAutosaveTickDoesNotSaveWhileTerminating() { var snapshotCallCount = 0 @@ -119,9 +219,9 @@ final class SessionAutosaveCoordinatorTests: XCTestCase { snapshotCallCount += 1 return Self.fakeSnapshot }, - saveSnapshot: { _, _ in + saveSnapshot: { _, _, completion in saveCallCount += 1 - return true + completion(true) }, isTerminating: { true }, isRunningUnderXCTest: { true } @@ -140,10 +240,10 @@ final class SessionAutosaveCoordinatorTests: XCTestCase { let coordinator = SessionAutosaveCoordinator( sessionPersistenceQueue: DispatchQueue(label: "test.session-autosave.prompt-save"), snapshotProvider: { _ in Self.fakeSnapshot }, - saveSnapshot: { _, _ in + saveSnapshot: { _, _, completion in saveCallCount += 1 saved.fulfill() - return true + completion(true) }, isTerminating: { false }, isRunningUnderXCTest: { true } diff --git a/programaTests/SessionPersistenceTests.swift b/programaTests/SessionPersistenceTests.swift index 222b9b00..dc2f4d9e 100644 --- a/programaTests/SessionPersistenceTests.swift +++ b/programaTests/SessionPersistenceTests.swift @@ -227,9 +227,9 @@ final class SessionPersistenceTests: XCTestCase { XCTAssertTrue(SessionPersistenceStore.save(snapshot, fileURL: snapshotURL)) // Force the live file's modification date later than every seeded entry above, so the // just-rotated copy is unambiguously the newest and the pruning boundary is deterministic. - let laterDate = try XCTUnwrap( - Calendar(identifier: .gregorian).date(from: DateComponents(year: 2026, month: 1, day: 2)) - ) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try XCTUnwrap(TimeZone(identifier: "UTC")) + let laterDate = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 1, day: 2))) try FileManager.default.setAttributes([.modificationDate: laterDate], ofItemAtPath: snapshotURL.path) XCTAssertTrue(SessionPersistenceStore.rotateIntoHistory(fileURL: snapshotURL, maxHistoryEntries: 10)) @@ -1038,6 +1038,86 @@ final class SessionPersistenceTests: XCTestCase { XCTAssertTrue(prepared.contains("\u{001B}[31m")) } + func testFreshSpawnScrollbackSeedBoundsRepeatedColorReplay() { + let count = 1_200 + let source = (0.. Bool { + SessionEscrowPolicy.shouldExpireSession( + sessionID: id.uuidString, isDraining: draining, + drainingStartedAt: since ?? expiredAt, now: now, + ownedSessionIDs: SessionPersistenceStore.ownedTerminalSessionIDs(fileURL: file) + ) + } + XCTAssertEqual(SessionPersistenceStore.ownedTerminalSessionIDs(fileURL: file), []) + XCTAssertTrue(expires(abandonedID)) + XCTAssertTrue(SessionPersistenceStore.save(makeOwnershipSnapshot(ownedID), fileURL: file)) + XCTAssertEqual(SessionPersistenceStore.ownedTerminalSessionIDs(fileURL: file), [ownedID.uuidString]) + XCTAssertFalse(expires(ownedID)) + XCTAssertTrue(expires(abandonedID)) + XCTAssertFalse(expires(abandonedID, draining: false)) + XCTAssertFalse(expires(abandonedID, since: now)) + try Data("invalid snapshot".utf8).write(to: file) + XCTAssertNil(SessionPersistenceStore.ownedTerminalSessionIDs(fileURL: file)) + XCTAssertFalse(expires(abandonedID)) + try FileManager.default.removeItem(at: file) + try FileManager.default.createDirectory(at: file, withIntermediateDirectories: true) + XCTAssertNil(SessionPersistenceStore.ownedTerminalSessionIDs(fileURL: file)) + XCTAssertFalse(expires(abandonedID)) + } + + func testNewEscrowRegistrationsUseVersionedBundleScopedHolderPaths() { + XCTAssertEqual( + SessionEscrowClient.escrowSocketPath(controlSocketPath: "/tmp/programa.sock"), + "/tmp/programa-escrow-v2.sock" + ) + XCTAssertEqual( + SessionEscrowClient.escrowSocketPath(controlSocketPath: "/tmp/programa-debug-review.sock"), + "/tmp/programa-debug-review-escrow-v2.sock" + ) + } + + func testOrphanSweepPreservesAllBundlesAndDefersWhenOwnershipIsUnknown() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let sessions = root.appendingPathComponent("sessions") + try FileManager.default.createDirectory(at: sessions, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let ownID = UUID(), otherID = UUID(), abandonedID = UUID(), registeredID = UUID() + let now = Date() + for id in [ownID, otherID, abandonedID, registeredID] { + let directory = sessions.appendingPathComponent(id.uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try FileManager.default.setAttributes( + [.modificationDate: now.addingTimeInterval(-SessionWALPolicy.orphanDirectoryMaxAge - 1)], + ofItemAtPath: directory.path + ) + } + let ownFile = try XCTUnwrap(SessionPersistenceStore.defaultSnapshotFileURL( + bundleIdentifier: "dev.own", appSupportDirectory: root + )).lastPathComponent + let otherFile = root.appendingPathComponent("session-production.json") + XCTAssertTrue(SessionPersistenceStore.save(makeOwnershipSnapshot(ownID), fileURL: root.appendingPathComponent(ownFile))) + XCTAssertTrue(SessionPersistenceStore.save(makeOwnershipSnapshot(otherID), fileURL: otherFile)) + XCTAssertEqual(SessionPersistenceStore.allOwnedTerminalSessionIDs(in: root), [ownID.uuidString, otherID.uuidString]) + try Data("corrupt".utf8).write(to: otherFile) + SessionWALStore.sweepOrphanedSessionDirectories(at: sessions, registeredSessionIDs: [], now: now) + XCTAssertTrue(FileManager.default.fileExists(atPath: sessions.appendingPathComponent(abandonedID.uuidString).path)) + XCTAssertTrue(SessionPersistenceStore.save(makeOwnershipSnapshot(otherID), fileURL: otherFile)) + SessionWALStore.sweepOrphanedSessionDirectories(at: sessions, registeredSessionIDs: [registeredID.uuidString], now: now) + for id in [ownID, otherID, registeredID] { + XCTAssertTrue(FileManager.default.fileExists(atPath: sessions.appendingPathComponent(id.uuidString).path)) + } + XCTAssertFalse(FileManager.default.fileExists(atPath: sessions.appendingPathComponent(abandonedID.uuidString).path)) + } + + private func makeOwnershipSnapshot(_ id: UUID) -> AppSessionSnapshot { + var snapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion) + snapshot.windows[0].tabManager.workspaces[0].panels = [SessionPanelSnapshot( + id: id, type: .terminal, title: nil, customTitle: nil, directory: nil, + isPinned: false, isManuallyUnread: false, gitBranch: nil, listeningPorts: [], ttyName: nil, + terminal: SessionTerminalPanelSnapshot(workingDirectory: nil, scrollback: nil), + browser: nil, markdown: nil, review: nil + )] + return snapshot + } + private func makeSnapshot(version: Int) -> AppSessionSnapshot { let workspace = SessionWorkspaceSnapshot( processTitle: "Terminal", diff --git a/programaTests/TabManagerSessionSnapshotTests.swift b/programaTests/TabManagerSessionSnapshotTests.swift index e9f2be5f..ace00262 100644 --- a/programaTests/TabManagerSessionSnapshotTests.swift +++ b/programaTests/TabManagerSessionSnapshotTests.swift @@ -46,4 +46,130 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertEqual(manager.tabs.count, 1) XCTAssertNotNil(manager.selectedTabId) } + + func testWorktreeParentPrefersSelectedRepositorySubdirectoryAndHonorsExplicitOwner() throws { + let manager = TabManager() + let folder = try XCTUnwrap(manager.selectedWorkspace) + folder.currentDirectory = "/tmp/project" + manager.enableWorktreeFolder(folder, repoRoot: "/tmp/project") + let selected = manager.addWorkspace(workingDirectory: "/tmp/project/Sources/../Sources", select: true) + + let child = manager.addWorktreeWorkspace( + path: "/tmp/worktrees/feature", branch: "feature", repoRoot: "/tmp/project", select: true + ) + XCTAssertEqual(child.worktreeParentWorkspaceId, selected.id) + XCTAssertNil(child.worktreeFolderId) + + let folderChild = manager.addWorktreeWorkspace( + path: "/tmp/worktrees/other", branch: "other", repoRoot: "/tmp/project", + parentWorkspaceId: folder.id, select: true + ) + XCTAssertEqual(folderChild.worktreeParentWorkspaceId, folder.id) + XCTAssertEqual(folderChild.worktreeFolderId, folder.worktreeFolderId) + } + + func testWorktreeParentDoesNotMatchSiblingRepositoryPathPrefix() throws { + let manager = TabManager() + let parent = try XCTUnwrap(manager.selectedWorkspace) + parent.currentDirectory = "/tmp/project" + _ = manager.addWorkspace(workingDirectory: "/tmp/project-other/Sources", select: true) + + let child = manager.addWorktreeWorkspace( + path: "/tmp/worktrees/feature", branch: "feature", repoRoot: "/tmp/project", select: true + ) + XCTAssertEqual(child.worktreeParentWorkspaceId, parent.id) + let detached = manager.addWorktreeWorkspace( + path: "/tmp/worktrees/detached", branch: "detached", repoRoot: "/tmp/project", + parentWorkspaceId: UUID(), select: true + ) + XCTAssertNil(detached.worktreeParentWorkspaceId) + } + + func testOrdinaryWorktreeParentSurvivesEncodedSessionRoundTrip() throws { + let manager = TabManager() + let parent = try XCTUnwrap(manager.selectedWorkspace) + parent.currentDirectory = "/tmp/project" + parent.setCustomTitle("Parent") + let child = manager.addWorktreeWorkspace( + path: "/tmp/worktrees/feature", branch: "feature", repoRoot: "/tmp/project", select: true + ) + child.setCustomTitle("Child") + let encoded = try JSONEncoder().encode(manager.sessionSnapshot(includeScrollback: false)) + let snapshot = try JSONDecoder().decode(SessionTabManagerSnapshot.self, from: encoded) + let restored = TabManager() + restored.restoreSessionSnapshot(snapshot) + let restoredParent = try XCTUnwrap(restored.tabs.first { $0.customTitle == "Parent" }) + let restoredChild = try XCTUnwrap(restored.tabs.first { $0.customTitle == "Child" }) + XCTAssertNotEqual(restoredParent.id, parent.id) + XCTAssertEqual(restoredChild.worktreeParentWorkspaceId, restoredParent.id) + XCTAssertEqual(restored.worktreeChildren(of: restoredParent).map(\.id), [restoredChild.id]) + } + + func testLegacyFolderMembershipRestoresWithoutSavedWorkspaceIds() throws { + let manager = TabManager() + let folder = try XCTUnwrap(manager.selectedWorkspace) + manager.enableWorktreeFolder(folder, repoRoot: "/tmp/project") + _ = manager.addWorktreeWorkspace( + path: "/tmp/worktrees/feature", branch: "feature", repoRoot: "/tmp/project", + parentWorkspaceId: folder.id, select: true + ) + var snapshot = manager.sessionSnapshot(includeScrollback: false) + for index in snapshot.workspaces.indices { + snapshot.workspaces[index].id = nil + snapshot.workspaces[index].worktreeParentWorkspaceId = nil + } + let encoded = try JSONEncoder().encode(snapshot) + let restored = TabManager() + restored.restoreSessionSnapshot(try JSONDecoder().decode(SessionTabManagerSnapshot.self, from: encoded)) + let restoredFolder = try XCTUnwrap(restored.tabs.first { $0.isWorktreeFolder }) + let child = try XCTUnwrap(restored.tabs.first { !$0.isWorktreeFolder }) + XCTAssertEqual(child.worktreeParentWorkspaceId, restoredFolder.id) + XCTAssertEqual(child.worktreeFolderId, restoredFolder.worktreeFolderId) + } + + func testRestoreDetachesInvalidParentReferences() throws { + let manager = TabManager() + _ = manager.addWorkspace(select: true) + let original = manager.sessionSnapshot(includeScrollback: false) + let firstId = try XCTUnwrap(original.workspaces[0].id) + let secondId = try XCTUnwrap(original.workspaces[1].id) + for invalidKind in ["self", "cycle", "missing", "duplicate"] { + var snapshot = original + switch invalidKind { + case "self": + snapshot.workspaces[0].worktreeParentWorkspaceId = firstId + case "cycle": + snapshot.workspaces[0].worktreeParentWorkspaceId = secondId + snapshot.workspaces[1].worktreeParentWorkspaceId = firstId + case "missing": + snapshot.workspaces[0].worktreeParentWorkspaceId = UUID() + default: + snapshot.workspaces[1].id = firstId + snapshot.workspaces[0].worktreeParentWorkspaceId = firstId + } + let restored = TabManager() + restored.restoreSessionSnapshot(snapshot) + XCTAssertTrue(restored.tabs.allSatisfy { $0.worktreeParentWorkspaceId == nil }, invalidKind) + } + } + + func testClosingOrMovingOrdinaryParentDetachesSurvivingChildren() throws { + for move in [false, true] { + let manager = TabManager() + let parent = try XCTUnwrap(manager.selectedWorkspace) + let child = manager.addWorktreeWorkspace( + path: "/tmp/worktrees/feature", branch: "feature", repoRoot: "/tmp/project", + parentWorkspaceId: parent.id, select: true + ) + if move { + XCTAssertEqual(manager.detachWorkspace(tabId: parent.id)?.id, parent.id) + } else { + manager.closeWorkspace(parent) + } + XCTAssertTrue(manager.tabs.contains { $0.id == child.id }) + XCTAssertNil(child.worktreeParentWorkspaceId) + XCTAssertNil(child.worktreeFolderId) + XCTAssertEqual(child.worktreeBranch, "feature") + } + } } diff --git a/programaTests/WindowAndDragTests.swift b/programaTests/WindowAndDragTests.swift index 96a902a6..38129223 100644 --- a/programaTests/WindowAndDragTests.swift +++ b/programaTests/WindowAndDragTests.swift @@ -123,6 +123,46 @@ final class WindowGlassEffectTests: XCTestCase { throw XCTSkip("Native Liquid Glass requires the macOS 26 SDK and runtime") } + func testNativePaneChromeBackgroundDragsWithoutTakingControlHits() throws { + #if compiler(>=6.2) + if #available(macOS 26.0, *) { + final class DragWindow: NSWindow { + var dragCount = 0 + var movableDuringDrag = false + override func performDrag(with event: NSEvent) { + dragCount += 1 + movableDuringDrag = isMovable + } + } + let window = DragWindow(contentRect: NSRect(x: 0, y: 0, width: 400, height: 100), + styleMask: [.titled], backing: .buffered, defer: false) + defer { window.orderOut(nil) } + window.isMovable = false + let background = PaneChromeDragBackgroundView(frame: NSRect(x: 30, y: 20, width: 320, height: 30)) + let root = try XCTUnwrap(window.contentView) + root.addSubview(background) + let button = NSButton(frame: NSRect(x: 10, y: 0, width: 30, height: 30)) + background.addSubview(button) + XCTAssertTrue(root.hitTest(NSPoint(x: 50, y: 35)) === button) + let hit = try XCTUnwrap(root.hitTest(NSPoint(x: 300, y: 35))) + XCTAssertTrue(hit === background) + let event = try XCTUnwrap(NSEvent.mouseEvent(with: .leftMouseDown, + location: NSPoint(x: 300, y: 35), modifierFlags: [], timestamp: 0, + windowNumber: window.windowNumber, context: nil, eventNumber: 1, clickCount: 1, pressure: 1)) + hit.mouseDown(with: event) + XCTAssertEqual(window.dragCount, 1) + XCTAssertTrue(window.movableDuringDrag) + XCTAssertFalse(window.isMovable) + _ = beginWindowDragSuppression(window: window) + hit.mouseDown(with: event) + _ = endWindowDragSuppression(window: window) + XCTAssertEqual(window.dragCount, 1) + return + } + #endif + throw XCTSkip("Native pane chrome requires the macOS 26 SDK and runtime") + } + func testNativeGlassContentHostOwnsItsSwiftUIControls() throws { #if compiler(>=6.2) if #available(macOS 26.0, *) { @@ -790,7 +830,7 @@ final class InternalTabDragBundleDeclarationTests: XCTestCase { final class WindowDragHandleHitTests: XCTestCase { private final class CapturingView: NSView { override func hitTest(_ point: NSPoint) -> NSView? { - bounds.contains(point) ? self : nil + super.hitTest(point) } } @@ -867,6 +907,23 @@ final class WindowDragHandleHitTests: XCTestCase { ) } + func testOffsetDragHandleYieldsToOffsetControlAndCapturesEmptySpace() { + let root = NSView(frame: NSRect(x: 0, y: 0, width: 600, height: 400)) + let container = NSView(frame: NSRect(x: 80, y: 120, width: 400, height: 80)) + root.addSubview(container) + let dragHandle = NSView(frame: NSRect(x: 40, y: 20, width: 320, height: 30)) + container.addSubview(dragHandle) + let control = NSButton(frame: NSRect(x: 180, y: 20, width: 40, height: 30)) + container.addSubview(control) + + XCTAssertFalse(windowDragHandleShouldCaptureHit( + NSPoint(x: 160, y: 15), in: dragHandle, eventType: .leftMouseDown + )) + XCTAssertTrue(windowDragHandleShouldCaptureHit( + NSPoint(x: 260, y: 15), in: dragHandle, eventType: .leftMouseDown + )) + } + func testDragHandleYieldsWhenSiblingClaimsPoint() { let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) let dragHandle = NSView(frame: container.bounds) diff --git a/programaUITests/BonsplitTabDragUITests.swift b/programaUITests/BonsplitTabDragUITests.swift index 1f504956..bf43917f 100644 --- a/programaUITests/BonsplitTabDragUITests.swift +++ b/programaUITests/BonsplitTabDragUITests.swift @@ -86,6 +86,42 @@ final class BonsplitTabDragUITests: XCTestCase { XCTAssertEqual(window.frame.origin.y, windowFrameBeforeDrag.origin.y, accuracy: 2.0, "Expected tab drag not to move the window vertically") } + func testStandardModeEmptyTopTabBarDragsWindowWithoutChangingTabs() throws { + let (app, dataPath) = launchConfiguredApp(presentationMode: .standard) + XCTAssertTrue(ensureForegroundAfterLaunch(app, timeout: launchTimeout)) + let ready = try XCTUnwrap(waitForJSONKey("ready", equals: "1", atPath: dataPath, timeout: setupTimeout)) + XCTAssertTrue((ready["setupError"] ?? "").isEmpty, "Setup failed: \(ready)") + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5)) + let alphaTitle = ready["alphaTitle"] ?? "UITest Alpha" + let betaTitle = ready["betaTitle"] ?? "UITest Beta" + let beta = app.buttons[betaTitle] + XCTAssertTrue(beta.waitForExistence(timeout: 5)) + let order = "\(alphaTitle)|\(betaTitle)" + XCTAssertNotNil(waitForJSONKey("trackedPaneTabTitles", equals: order, atPath: dataPath, timeout: 5)) + + let before = window.frame + // Stay beyond the last tab and its plus button, before the trailing controls. + let x = min(before.maxX - 180, beta.frame.maxX + 90) + XCTAssertGreaterThan(x, beta.frame.maxX + 44, "Expected empty tab-strip space") + let start = window.coordinate(withNormalizedOffset: .zero).withOffset( + CGVector(dx: x - before.minX, dy: beta.frame.midY - before.minY) + ) + start.press(forDuration: 0.1, thenDragTo: start.withOffset(CGVector(dx: 40, dy: 35))) + + XCTAssertTrue(waitForCondition(timeout: 5) { + hypot(window.frame.minX - before.minX, window.frame.minY - before.minY) >= 20 + }, "Empty top tab-strip drag should move the window. before=\(before), after=\(window.frame)") + XCTAssertEqual(window.frame.width, before.width, accuracy: 2) + XCTAssertEqual(window.frame.height, before.height, accuracy: 2) + XCTAssertEqual(app.windows.count, 1) + XCTAssertEqual(loadJSON(atPath: dataPath)?["trackedPaneTabTitles"], order) + XCTAssertEqual(loadJSON(atPath: dataPath)?["trackedPaneTabCount"], "2") + let screenshot = XCTAttachment(screenshot: window.screenshot()) + screenshot.lifetime = .keepAlways + add(screenshot) + } + func testMinimalModePlacesPaneTabBarAtTopEdge() { let (app, dataPath) = launchConfiguredApp() diff --git a/programaUITests/CloseWindowConfirmDialogUITests.swift b/programaUITests/CloseWindowConfirmDialogUITests.swift index 3775487d..3881b7e3 100644 --- a/programaUITests/CloseWindowConfirmDialogUITests.swift +++ b/programaUITests/CloseWindowConfirmDialogUITests.swift @@ -1,135 +1,74 @@ import XCTest final class CloseWindowConfirmDialogUITests: XCTestCase { - private let launchTag = "ui-tests-close-window-confirm" - override func setUp() { super.setUp() continueAfterFailure = false } - func testCmdCtrlWShowsCloseWindowConfirmationText() { - let app = XCUIApplication() - app.launchEnvironment["PROGRAMA_TAG"] = launchTag - app.launch() - XCTAssertTrue( - ensureForegroundAfterLaunch(app, timeout: 12.0), - "Expected app to launch for close-window confirmation test. state=\(app.state.rawValue)" - ) - - app.typeKey("w", modifierFlags: [.command, .control]) - - XCTAssertTrue( - waitForCloseWindowAlert(app: app, timeout: 5.0), - "Expected Cmd+Ctrl+W to show the close window confirmation alert" - ) - - clickCancelOnCloseWindowAlert(app: app) + func testCloseShortcutPreservesRunningShellOnReopen() throws { + try assertRunningShellSurvivesClose(useNativeButton: false) + } - XCTAssertFalse( - isCloseWindowAlertPresent(app: app), - "Expected close window confirmation alert to dismiss after clicking Cancel" - ) - XCTAssertTrue(app.windows.firstMatch.exists, "Expected the window to remain open after cancelling close") + func testNativeCloseButtonPreservesRunningShellOnReopen() throws { + try assertRunningShellSurvivesClose(useNativeButton: true) } - func testReturnConfirmsCloseWindowDialog() { + private func assertRunningShellSurvivesClose(useNativeButton: Bool) throws { + let token = UUID().uuidString + let before = URL(fileURLWithPath: "/tmp/programa-close-before-\(token)") + let after = URL(fileURLWithPath: "/tmp/programa-close-after-\(token)") + defer { + try? FileManager.default.removeItem(at: before) + try? FileManager.default.removeItem(at: after) + } let app = XCUIApplication() - app.launchEnvironment["PROGRAMA_TAG"] = launchTag + app.launchEnvironment["PROGRAMA_TAG"] = "ui-tests-close-\(token.lowercased())" app.launch() - XCTAssertTrue( - ensureForegroundAfterLaunch(app, timeout: 12.0), - "Expected app to launch for close-window confirmation test. state=\(app.state.rawValue)" - ) - - app.typeKey("w", modifierFlags: [.command, .control]) - - XCTAssertTrue( - waitForCloseWindowAlert(app: app, timeout: 5.0), - "Expected Cmd+Ctrl+W to show the close window confirmation alert" - ) + if !app.wait(for: .runningForeground, timeout: 12) { app.activate() } + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 6)) + XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 5)) + // A fresh shell displaying old output cannot preserve both its process + // identity and this shell-local variable. + app.typeText("PROGRAMA_REOPEN_TOKEN=\(token); printf '%s\\n%s\\n' \"$$\" \"$PROGRAMA_REOPEN_TOKEN\" > '\(before.path)'") app.typeKey(.return, modifierFlags: []) + XCTAssertTrue(waitUntil { self.shellRecord(at: before)?.contains(token) == true }) + let expected = try XCTUnwrap(shellRecord(at: before)) - XCTAssertTrue( - waitForCloseWindowAlertToDismiss(app: app, timeout: 5.0), - "Expected Return to dismiss the close window confirmation alert" - ) - XCTAssertTrue( - waitForMainWindowToClose(app: app, timeout: 5.0), - "Expected Return to confirm window close" - ) - } - - private func isCloseWindowAlertPresent(app: XCUIApplication) -> Bool { - if closeWindowDialog(app: app).exists { return true } - if closeWindowAlert(app: app).exists { return true } - return app.staticTexts["Close window?"].exists - } + if useNativeButton { + app.windows.firstMatch.buttons[XCUIIdentifierCloseWindow].click() + } else { + app.typeKey("w", modifierFlags: [.command, .control]) + } + XCTAssertTrue(waitUntil { app.windows.count == 0 }, "Closing should hide the window without a confirmation dialog") + XCTAssertFalse(app.staticTexts["Close window?"].exists) + XCTAssertNotEqual(app.state, .notRunning, "Closing a window must keep its sessions running") + + app.typeKey("n", modifierFlags: [.command, .shift]) + XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 5)) + XCTAssertEqual(app.windows.count, 1, "New Window should reopen the preserved window") + app.typeText("printf '%s\\n%s\\n' \"$$\" \"$PROGRAMA_REOPEN_TOKEN\" > '\(after.path)'") + app.typeKey(.return, modifierFlags: []) + XCTAssertTrue(waitUntil { self.shellRecord(at: after) != nil }) + XCTAssertEqual(try XCTUnwrap(shellRecord(at: after)), expected, "Reopen must preserve the running shell, not just its text") - private func waitForCloseWindowAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool { - let expectation = XCTNSPredicateExpectation( - predicate: NSPredicate { _, _ in - self.isCloseWindowAlertPresent(app: app) - }, - object: NSObject() - ) - return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Reopened preserved terminal" + attachment.lifetime = .keepAlways + add(attachment) } - private func waitForCloseWindowAlertToDismiss(app: XCUIApplication, timeout: TimeInterval) -> Bool { - let expectation = XCTNSPredicateExpectation( - predicate: NSPredicate { _, _ in - !self.isCloseWindowAlertPresent(app: app) - }, - object: NSObject() - ) - return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed + private func shellRecord(at url: URL) -> String? { + guard let value = try? String(contentsOf: url, encoding: .utf8), + value.split(separator: "\n", omittingEmptySubsequences: false).count >= 3 else { return nil } + return value } - private func waitForMainWindowToClose(app: XCUIApplication, timeout: TimeInterval) -> Bool { + private func waitUntil(_ condition: @escaping () -> Bool) -> Bool { let expectation = XCTNSPredicateExpectation( - predicate: NSPredicate { _, _ in - !app.windows.firstMatch.exists - }, - object: NSObject() + predicate: NSPredicate { _, _ in condition() }, object: NSObject() ) - return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed - } - - private func clickCancelOnCloseWindowAlert(app: XCUIApplication) { - let dialog = closeWindowDialog(app: app) - if dialog.exists { - dialog.buttons["Cancel"].firstMatch.click() - return - } - let alert = closeWindowAlert(app: app) - if alert.exists { - alert.buttons["Cancel"].firstMatch.click() - return - } - let anyDialog = app.dialogs.firstMatch - if anyDialog.exists, anyDialog.buttons["Cancel"].exists { - anyDialog.buttons["Cancel"].firstMatch.click() - } - } - - private func closeWindowDialog(app: XCUIApplication) -> XCUIElement { - app.dialogs.containing(.staticText, identifier: "Close window?").firstMatch - } - - private func closeWindowAlert(app: XCUIApplication) -> XCUIElement { - app.alerts.containing(.staticText, identifier: "Close window?").firstMatch - } - - private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { - if app.wait(for: .runningForeground, timeout: timeout) { - return true - } - if app.state == .runningBackground { - app.activate() - return app.wait(for: .runningForeground, timeout: 6.0) - } - return false + return XCTWaiter().wait(for: [expectation], timeout: 10) == .completed } } diff --git a/scripts/check-structural-budgets.py b/scripts/check-structural-budgets.py index 9869182f..6e3d15f8 100755 --- a/scripts/check-structural-budgets.py +++ b/scripts/check-structural-budgets.py @@ -31,6 +31,7 @@ class FamilyBudget: FILES = ( FileBudget("Sources/AppDelegate.swift", 11_078, 10_938, "application lifecycle entrypoint"), FileBudget("Sources/AppLifecycleCoordinator.swift", 0, 125, "AppLifecycleCoordinator"), + FileBudget("Sources/AppDelegate+StartupHandoff.swift", 0, 75, "StartupSessionHandoff"), FileBudget("Sources/ContentView.swift", 5_949, 5_728, "command palette view adapter"), FileBudget("Sources/CommandPaletteController.swift", 71, 283, "CommandPaletteController"), FileBudget("CLI/programa.swift", 7_886, 7_584, "ProgramaCLI entrypoint"), @@ -53,6 +54,7 @@ class FamilyBudget: ( "Sources/AppDelegate.swift", "Sources/AppLifecycleCoordinator.swift", + "Sources/AppDelegate+StartupHandoff.swift", "Sources/ContentView.swift", "Sources/CommandPaletteController.swift", ), diff --git a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/TabBarView.swift b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/TabBarView.swift index b49100ed..2b6a84d4 100644 --- a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -107,7 +107,7 @@ struct TabBarView: View { var body: some View { HStack(spacing: 0) { if appearance.tabBarLeadingInset > 0 && controller.internalController.rootNode.allPaneIds.first == pane.id { - TabBarDragZoneView { return false } + TabBarDragZoneView() .frame(width: appearance.tabBarLeadingInset) } // Scrollable tabs with fade overlays @@ -159,11 +159,7 @@ struct TabBarView: View { .overlay(alignment: .trailing) { let trailing = max(0, containerGeo.size.width - contentWidth) if trailing >= 1 { - TabBarDragZoneView { - guard splitViewController.isInteractive else { return false } - controller.requestNewTab(kind: "terminal", inPane: pane.id) - return true - } + TabBarDragZoneView() .frame(width: trailing, height: TabBarMetrics.tabHeight) .onDrop(of: [.tabTransfer], delegate: TabDropDelegate( targetIndex: pane.tabs.count, @@ -218,7 +214,6 @@ struct TabBarView: View { .coordinateSpace(name: "tabBar") .background(tabBarBackground) .background(TabBarDragAndHoverView( - isMinimalMode: presentationMode == "minimal", onHoverChanged: { isHoveringTabBar = $0 } )) .background( @@ -447,11 +442,7 @@ struct TabBarView: View { @ViewBuilder private var dropZoneAfterTabs: some View { - TabBarDragZoneView { - guard splitViewController.isInteractive else { return false } - controller.requestNewTab(kind: "terminal", inPane: pane.id) - return true - } + TabBarDragZoneView() .frame(width: 30, height: TabBarMetrics.tabHeight) .onDrop(of: [.tabTransfer], delegate: TabDropDelegate( targetIndex: pane.tabs.count, @@ -473,12 +464,10 @@ struct TabBarView: View { /// Fixed-width gutter reserved behind the split-action icon cluster (terminal/browser/ /// split buttons), matching the 114pt previously consumed by trailing padding. Kept as a - /// real drag-capturing view (not bare padding) so window drag works there. Double-click - /// falls through to the same drag path (no new-tab action) — consistent with the rest of - /// this empty strip in standard presentation mode. + /// real drag-capturing view so window drag works there. @ViewBuilder private var splitButtonsGutterDragZone: some View { - TabBarDragZoneView { false } + TabBarDragZoneView() .frame(width: 114, height: TabBarMetrics.tabHeight) } @@ -688,28 +677,24 @@ private struct SplitActionButtonStyle: ButtonStyle { } } -/// Background view that provides window-drag-from-empty-space in minimal mode +/// Background view that provides window-drag-from-empty-space /// and hover tracking via NSTrackingArea (replacing .contentShape + .onHover). /// As a .background(), AppKit routes clicks to tabs/buttons in front first; /// this view only receives hits in truly empty space. private struct TabBarDragAndHoverView: NSViewRepresentable { - let isMinimalMode: Bool let onHoverChanged: (Bool) -> Void func makeNSView(context: Context) -> TabBarBackgroundNSView { let view = TabBarBackgroundNSView() - view.isMinimalMode = isMinimalMode view.onHoverChanged = onHoverChanged return view } func updateNSView(_ nsView: TabBarBackgroundNSView, context: Context) { - nsView.isMinimalMode = isMinimalMode nsView.onHoverChanged = onHoverChanged } final class TabBarBackgroundNSView: NSView { - var isMinimalMode = false var onHoverChanged: ((Bool) -> Void)? private var hoverTrackingArea: NSTrackingArea? @@ -743,15 +728,7 @@ private struct TabBarDragAndHoverView: NSViewRepresentable { return } if event.clickCount >= 2 { - guard isMinimalMode else { - super.mouseDown(with: event) - return - } - let action = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)?["AppleActionOnDoubleClick"] as? String - switch action { - case "Minimize": window.miniaturize(nil) - default: window.zoom(nil) - } + performTabBarDoubleClick(window: window) return } let wasMovable = window.isMovable @@ -762,32 +739,38 @@ private struct TabBarDragAndHoverView: NSViewRepresentable { } } -private struct TabBarDragZoneView: NSViewRepresentable { - let onDoubleClick: () -> Bool +private func performTabBarDoubleClick(window: NSWindow) { + let defaults = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain) ?? [:] + let action = (defaults["AppleActionOnDoubleClick"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + switch action { + case "minimize", "miniaturize": window.miniaturize(nil) + case "none", "no action": break + case "maximize", "zoom", "fill": window.zoom(nil) + default: + if defaults["AppleMiniaturizeOnDoubleClick"] as? Bool == true { + window.miniaturize(nil) + } else { + window.zoom(nil) + } + } +} +struct TabBarDragZoneView: NSViewRepresentable { func makeNSView(context: Context) -> DragNSView { let view = DragNSView() - view.onDoubleClick = onDoubleClick view.wantsLayer = true view.layer?.backgroundColor = NSColor.clear.cgColor return view } - func updateNSView(_ nsView: DragNSView, context: Context) { - nsView.onDoubleClick = onDoubleClick - } + func updateNSView(_ nsView: DragNSView, context: Context) {} final class DragNSView: NSView { - var onDoubleClick: (() -> Bool)? - override var mouseDownCanMoveWindow: Bool { return true } - override func hitTest(_ point: NSPoint) -> NSView? { - return bounds.contains(point) ? self : nil - } - override func mouseDown(with event: NSEvent) { guard let window = self.window else { super.mouseDown(with: event) @@ -795,18 +778,8 @@ private struct TabBarDragZoneView: NSViewRepresentable { } if event.clickCount >= 2 { - if UserDefaults.standard.string(forKey: "workspacePresentationMode") == "minimal" { - let action = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)?["AppleActionOnDoubleClick"] as? String - switch action { - case "Minimize": window.miniaturize(nil) - default: window.zoom(nil) - } - return - } else { - if onDoubleClick?() == true { - return - } - } + performTabBarDoubleClick(window: window) + return } let wasMovable = window.isMovable diff --git a/vendor/bonsplit/Tests/BonsplitTests/BonsplitTests.swift b/vendor/bonsplit/Tests/BonsplitTests/BonsplitTests.swift index 6c7d9792..de355bc2 100644 --- a/vendor/bonsplit/Tests/BonsplitTests/BonsplitTests.swift +++ b/vendor/bonsplit/Tests/BonsplitTests/BonsplitTests.swift @@ -4,6 +4,20 @@ import AppKit import SwiftUI final class BonsplitTests: XCTestCase { + @MainActor + func testTabBarDragZoneUsesSuperviewCoordinatesAndYieldsToControls() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 600, height: 100)) + let dragZone = TabBarDragZoneView.DragNSView(frame: NSRect(x: 240, y: 40, width: 200, height: 30)) + container.addSubview(dragZone) + XCTAssertTrue(container.hitTest(NSPoint(x: 420, y: 55)) === dragZone) + XCTAssertNil(dragZone.hitTest(NSPoint(x: 20, y: 15))) + let button = NSButton(frame: NSRect(x: 380, y: 40, width: 40, height: 30)) + container.addSubview(button) + XCTAssertTrue(container.hitTest(NSPoint(x: 400, y: 55)) === button) + dragZone.isHidden = true + XCTAssertNil(dragZone.hitTest(NSPoint(x: 430, y: 55))) + } + @MainActor private final class LayoutProbeView: NSView { private(set) var sizeChangeCount = 0 From d1c8d3a9479e6992f08542eecbc204754917c37b Mon Sep 17 00:00:00 2001 From: arzafran Date: Mon, 14 Sep 2026 01:31:26 +0200 Subject: [PATCH 2/2] fix: expose native tabs to accessibility --- Sources/WindowPaneChromePortal.swift | 8 ++++++++ programaTests/WindowAndDragTests.swift | 13 ++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/Sources/WindowPaneChromePortal.swift b/Sources/WindowPaneChromePortal.swift index 984f9fd0..72a802cb 100644 --- a/Sources/WindowPaneChromePortal.swift +++ b/Sources/WindowPaneChromePortal.swift @@ -883,6 +883,7 @@ private final class NativeTabPillControl: NSControl, NSMenuDelegate, NSDraggingS addSubview(iconView) addSubview(titleField) addSubview(closeButton) + setAccessibilityElement(true) setAccessibilityRole(.button) } @@ -954,9 +955,16 @@ private final class NativeTabPillControl: NSControl, NSMenuDelegate, NSDraggingS toolTip = tab.title setAccessibilityLabel(tab.title) setAccessibilityValue(tab.accessibilityValue) + setAccessibilitySelected(tab.isSelected) needsLayout = true } + override func accessibilityPerformPress() -> Bool { + guard isEnabled, let selectAction else { return false } + selectAction() + return true + } + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } // Full Keyboard Access: the pill joins the key-view loop, activates on diff --git a/programaTests/WindowAndDragTests.swift b/programaTests/WindowAndDragTests.swift index 38129223..87b7006a 100644 --- a/programaTests/WindowAndDragTests.swift +++ b/programaTests/WindowAndDragTests.swift @@ -87,6 +87,7 @@ final class WindowGlassEffectTests: XCTestCase { let bridge = WindowPaneChromePortalRegistry.bridge(for: window) let paneID = PaneID() + var accessibilitySelection: TabID? let tabs = ["One", "Two"].map { title in BonsplitPaneChromeTabDescriptor( id: TabID(), title: title, icon: "terminal", iconImageData: nil, @@ -97,7 +98,7 @@ final class WindowGlassEffectTests: XCTestCase { bridge.updatePaneChrome(BonsplitPaneChromeDescriptor( paneID: paneID, anchorView: anchor, tabs: tabs, isFocused: true, isVisible: true, leadingInset: 0, showsSplitButtons: false, - onSelect: { _ in }, onClose: { _ in }, onContextAction: { _, _ in }, + onSelect: { accessibilitySelection = $0 }, onClose: { _ in }, onContextAction: { _, _ in }, dragPasteboardData: { _ in nil }, onDragStateChanged: { _, _ in }, onNewTab: {}, onNewBrowserTab: {}, onSplitRight: {}, onSplitDown: {} )) @@ -109,6 +110,16 @@ final class WindowGlassEffectTests: XCTestCase { // is a plain button container. let pillGlassViews = glassViews.filter { $0.contentView is NSControl } XCTAssertEqual(pillGlassViews.count, 2) + for tab in tabs { + let control = try XCTUnwrap(pillGlassViews.compactMap { $0.contentView as? NSControl } + .first { $0.accessibilityLabel() == tab.title }) + XCTAssertTrue(control.isAccessibilityElement()) + XCTAssertEqual(control.accessibilityRole(), .button) + XCTAssertEqual(control.isAccessibilitySelected(), tab.isSelected) + XCTAssertEqual(control.accessibilityValue() as? String, tab.accessibilityValue) + XCTAssertTrue(control.accessibilityPerformPress()) + XCTAssertEqual(accessibilitySelection, tab.id) + } XCTAssertEqual(glassViews.count, 4) XCTAssertTrue(bridge.hostViewForTesting.superview === terminalHost.superview) let siblings = terminalHost.superview?.subviews ?? []