diff --git a/CLAUDE.md b/CLAUDE.md index b0da420985c..c3ea96eadea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,48 @@ # cmux agent notes +## Personal fork notes (read first) + +This repo (`TraderSamwise/cmux`) is **sam's personal fork** of [`manaflow-ai/cmux`](https://github.com/manaflow-ai/cmux). The long-lived feature branch is `feature/quick-terminal-pr`. Two things to know before building or merging from upstream: + +### Quick terminal (visor / quake-mode) + +The fork adds a quick-terminal panel implemented in `Sources/QuickTerminalController.swift`. It registers as a `mainWindowContext` just like a regular main window and runs the same `ContentView`. When merging from upstream `main`: + +- If a new `@EnvironmentObject` is added to `ContentView` (`Sources/ContentView.swift` ~L1051), mirror the injection in `QuickTerminalController.createQuickTerminalWindow` and pass it through `registerMainWindow(...)`. Otherwise the visor will SIGILL the instant SwiftUI evaluates the body. +- Current required env objects: `TabManager`, `TerminalNotificationStore`, `SidebarState`, `SidebarSelectionState`, `FileExplorerState`, `CmuxConfigStore`. + +### Ad-hoc Release signing only + +This fork is built only with **ad-hoc signing** — no Apple Developer cert, no provisioning profile. To keep that working: + +- `Resources/cmux.entitlements` (added upstream in PR #3027 for auth scaffolding) has been **intentionally deleted**. Do not reintroduce it: `keychain-access-groups` cannot be carried by an ad-hoc signature, and codesign refuses the build with *"entitlements that require signing with a development certificate"*. +- The Release config's `CODE_SIGN_ENTITLEMENTS` is set to `""` (see `GhosttyTabs.xcodeproj/project.pbxproj`). Keep it that way. +- The auth code's `FallbackTokenStore` (`Sources/Auth/AuthManager.swift`) already drops to a file store when keychain writes fail, so dropping the entitlement has no runtime impact for personal use. +- Other repo-root entitlement files (`cmux.release.entitlements`, `cmux.nightly.entitlements`, `cmux-helper.entitlements`, `cmux.entitlements`) are consumed only by `scripts/sign-cmux-bundle.sh`, `scripts/build-sign-upload.sh`, and the release/nightly GitHub workflows. They do **not** affect local `xcodebuild` Release builds — leave them alone unless you plan to run those signing scripts. + +### Building on a fresh machine (e.g., the other laptop) + +```bash +git clone git@github.com:TraderSamwise/cmux.git +cd cmux +git checkout feature/quick-terminal-pr +./scripts/setup.sh # init submodules + build GhosttyKit.xcframework +./scripts/reloadp.sh # build Release and launch (works ad-hoc) +``` + +Install over `/Applications/cmux.app` once you're happy: + +```bash +pkill -x cmux; rm -rf /Applications/cmux.app \ + && ditto "$(find "$HOME/Library/Developer/Xcode/DerivedData" -path '*/Build/Products/Release/cmux.app' -print0 | xargs -0 stat -f '%m %N' | sort -nr | head -n1 | cut -d' ' -f2-)" /Applications/cmux.app \ + && touch /Applications/cmux.app \ + && open /Applications/cmux.app +``` + +(The `find | sort -nr | head -n1` picks the newest Release `cmux.app` from DerivedData — the directory hash is project-path-dependent and will differ across machines.) + +If `xcodebuild` fails with *"entitlements that require signing with a development certificate"*, the upstream `Resources/cmux.entitlements` has been re-added by an unintended merge. Check `git status`, delete it, and verify `CODE_SIGN_ENTITLEMENTS = "";` in the Release config of `GhosttyTabs.xcodeproj/project.pbxproj`. + ## Initial setup Run the setup script to initialize submodules and build GhosttyKit: diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index b7a69c84acd..94662058241 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -190,6 +190,24 @@ E7E00000000000000000000B /* CmuxLifecycleEventPublishing.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7E00000000000000000000C /* CmuxLifecycleEventPublishing.swift */; }; E7E000000000000000000007 /* CmuxEventPublishing.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7E000000000000000000008 /* CmuxEventPublishing.swift */; }; A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; }; + A5001500 /* CmuxWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001510 /* CmuxWebView.swift */; }; + A5001501 /* UITestRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001511 /* UITestRecorder.swift */; }; + A5001226 /* SocketControlSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001225 /* SocketControlSettings.swift */; }; + A5009901 /* QuickTerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5009902 /* QuickTerminalController.swift */; }; + A5001601 /* SentryHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001600 /* SentryHelper.swift */; }; + A5001621 /* AppleScriptSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001620 /* AppleScriptSupport.swift */; }; + A5001400 /* Panel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001410 /* Panel.swift */; }; + A5001401 /* TerminalPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001411 /* TerminalPanel.swift */; }; + A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; }; + A5001403 /* TerminalPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001413 /* TerminalPanelView.swift */; }; + A5001404 /* BrowserPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001414 /* BrowserPanelView.swift */; }; + A5007420 /* BrowserPopupWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5007421 /* BrowserPopupWindowController.swift */; }; + A5001420 /* MarkdownPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001418 /* MarkdownPanel.swift */; }; + A5001421 /* MarkdownPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001419 /* MarkdownPanelView.swift */; }; + A5001290 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = A5001291 /* MarkdownUI */; }; + A5001405 /* PanelContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001415 /* PanelContentView.swift */; }; + A5001406 /* Workspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001416 /* Workspace.swift */; }; + A5001407 /* WorkspaceContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001417 /* WorkspaceContentView.swift */; }; E7E000000000000000000001 /* CmuxEventStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7E000000000000000000002 /* CmuxEventStream.swift */; }; E7E000000000000000000009 /* CmuxSocketEventMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7E00000000000000000000A /* CmuxSocketEventMapper.swift */; }; D7AB0000000000000000000B /* TerminalController+MoveTabToNewWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB0000000000000000000C /* TerminalController+MoveTabToNewWorkspace.swift */; }; @@ -694,6 +712,24 @@ A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = ""; }; A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = ""; }; A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = ""; }; + A5001600 /* SentryHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryHelper.swift; sourceTree = ""; }; + A5001620 /* AppleScriptSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScriptSupport.swift; sourceTree = ""; }; + A5001510 /* CmuxWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CmuxWebView.swift; sourceTree = ""; }; + A5001511 /* UITestRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestRecorder.swift; sourceTree = ""; }; + A5001520 /* PostHogAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalytics.swift; sourceTree = ""; }; + A5001225 /* SocketControlSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlSettings.swift; sourceTree = ""; }; + A5009902 /* QuickTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalController.swift; sourceTree = ""; }; + A5001410 /* Panel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/Panel.swift; sourceTree = ""; }; + A5001411 /* TerminalPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanel.swift; sourceTree = ""; }; + A5001412 /* BrowserPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanel.swift; sourceTree = ""; }; + A5001413 /* TerminalPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanelView.swift; sourceTree = ""; }; + A5001414 /* BrowserPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelView.swift; sourceTree = ""; }; + A5007421 /* BrowserPopupWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPopupWindowController.swift; sourceTree = ""; }; + A5001415 /* PanelContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/PanelContentView.swift; sourceTree = ""; }; + A5001418 /* MarkdownPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanel.swift; sourceTree = ""; }; + A5001419 /* MarkdownPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanelView.swift; sourceTree = ""; }; + A5001416 /* Workspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Workspace.swift; sourceTree = ""; }; + A5001417 /* WorkspaceContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentView.swift; sourceTree = ""; }; E7E000000000000000000006 /* CmuxEventBus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxEventBus.swift; sourceTree = ""; }; E7E00000000000000000000E /* CmuxEventLogWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxEventLogWriter.swift; sourceTree = ""; }; E7E00000000000000000000C /* CmuxLifecycleEventPublishing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxLifecycleEventPublishing.swift; sourceTree = ""; }; @@ -1267,6 +1303,7 @@ A5001544 /* TerminalImageTransfer.swift */, A5001545 /* TerminalSSHSessionDetector.swift */, A5001225 /* SocketControlSettings.swift */, + A5009902 /* QuickTerminalController.swift */, A5001600 /* SentryHelper.swift */, A5001620 /* AppleScriptSupport.swift */, D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */, @@ -1949,6 +1986,7 @@ A5001542 /* TerminalImageTransfer.swift in Sources */, A5001543 /* TerminalSSHSessionDetector.swift in Sources */, A5001226 /* SocketControlSettings.swift in Sources */, + A5009901 /* QuickTerminalController.swift in Sources */, A5001601 /* SentryHelper.swift in Sources */, A5001621 /* AppleScriptSupport.swift in Sources */, C3873001C3873001C3873001 /* GhosttyCrashBreadcrumb.swift in Sources */, @@ -2418,7 +2456,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CMUX_AUTH_CALLBACK_SCHEME = cmux; - CODE_SIGN_ENTITLEMENTS = Resources/cmux.entitlements; + CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 86; diff --git a/README.md b/README.md index 2376987431f..302942b5538 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@

cmux

A Ghostty-based macOS terminal with vertical tabs and notifications for AI coding agents

+> **Personal fork notice.** `TraderSamwise/cmux` is sam's personal fork; the long-lived branch is `feature/quick-terminal-pr`. It adds a quake-style quick-terminal panel and is built only with ad-hoc signing (no Apple Developer cert required). Build and merge notes specific to this fork live in [`CLAUDE.md`](./CLAUDE.md#personal-fork-notes-read-first). Original upstream: [manaflow-ai/cmux](https://github.com/manaflow-ai/cmux). +

Download cmux for macOS diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 1aeae10abf0..8683737f4f2 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -103983,6 +103983,23 @@ } } }, + "quickTerminal.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Quick Terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クイックターミナル" + } + } + } + }, "menu.openInIntelliJ": { "extractionState": "manual", "localizations": { diff --git a/Resources/cmux.entitlements b/Resources/cmux.entitlements deleted file mode 100644 index 02d841c2da1..00000000000 --- a/Resources/cmux.entitlements +++ /dev/null @@ -1,11 +0,0 @@ - - - - - keychain-access-groups - - $(AppIdentifierPrefix)$(CFBundleIdentifier) - $(CFBundleIdentifier) - - - diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 0afdc615a32..cecebaf3240 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -854,6 +854,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var sessionAutosaveTimer: DispatchSourceTimer? private var sessionAutosaveTickInFlight = false private var sessionAutosaveDeferredRetryPending = false + /// Tracks whether the last successfully persisted snapshot had real content, + /// so a subsequent "trivial" snapshot (1 default workspace, no state) can't + /// silently wipe a richer prior session after a hard crash. + private var lastPersistedSnapshotIsNonTrivial = false + private var socketListenerHealthTimer: DispatchSourceTimer? + private var socketListenerHealthCheckInFlight = false + private static let socketListenerHealthCheckInterval: DispatchTimeInterval = .seconds(2) + private var lastSocketListenerUnhealthyCaptureAt: Date = .distantPast + private static let socketListenerUnhealthyCaptureCooldown: TimeInterval = 60 private let sessionPersistenceQueue = DispatchQueue( label: "com.cmuxterm.app.sessionPersistence", qos: .utility @@ -1125,6 +1134,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self?.observeDuplicateLaunches() } } + // Load quick terminal config early, before any window creation can + // trigger session restore (which checks visorEnabled). + installQuickTerminal() + NSWindow.allowsAutomaticWindowTabbing = false disableNativeTabbingShortcut() if !isRunningUnderXCTest { @@ -2647,7 +2660,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent Self.removeLegacyPersistedWindowGeometry() SessionPersistenceStore.syncManualRestoreSnapshotCache() guard SessionRestorePolicy.shouldAttemptRestore() else { return } - startupSessionSnapshot = SessionPersistenceStore.load() + let loaded = SessionPersistenceStore.load() + startupSessionSnapshot = loaded + lastPersistedSnapshotIsNonTrivial = !Self.isTrivialSnapshot(loaded) } private func persistedWindowGeometry(defaults: UserDefaults = .standard) -> PersistedWindowGeometry? { @@ -2745,7 +2760,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let primaryContext = contextForMainTerminalWindow(primaryWindow) else { return } let startupSnapshot = startupSessionSnapshot - let primaryWindowSnapshot = startupSnapshot?.windows.first + + // Ensure quick terminal config is loaded (installQuickTerminal may not + // have run yet since SwiftUI window creation can precede didFinishLaunching). + QuickTerminalController.shared.loadConfiguration() + + // Separate quick terminal snapshot from regular windows. If the visor + // hotkey is no longer configured, treat the visor snapshot as a regular + // window so its workspaces aren't silently dropped. + let visorEnabled = QuickTerminalController.shared.keybind != nil + let quickTerminalSnapshot = visorEnabled + ? startupSnapshot?.windows.first(where: { $0.isQuickTerminal == true }) + : nil + let regularWindows = startupSnapshot?.windows.filter { window in + if visorEnabled { return window.isQuickTerminal != true } + return true + } ?? [] + + // Stash the visor snapshot for deferred restore when the visor window + // is first created (on toggle or immediately below). + if let quickTerminalSnapshot { + QuickTerminalController.shared.restoreSession(quickTerminalSnapshot) + } + + let primaryWindowSnapshot = regularWindows.first if let primaryWindowSnapshot { isApplyingSessionRestore = true #if DEBUG @@ -2760,6 +2798,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent to: primaryContext, window: primaryWindow ) + } else if quickTerminalSnapshot != nil, QuickTerminalController.shared.keybind != nil { + // No regular windows to restore — hide the SwiftUI-created primary + // window and show the visor with the restored session. + primaryWindow.orderOut(nil) + DispatchQueue.main.async { + primaryWindow.close() + QuickTerminalController.shared.toggle() + } } else { let displays = currentDisplayGeometries() let fallbackGeometry = persistedWindowGeometry() @@ -2774,9 +2820,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } - if let startupSnapshot { - let additionalWindows = Array(startupSnapshot - .windows + if startupSnapshot != nil { + let additionalWindows = Array(regularWindows .dropFirst() .prefix(max(0, SessionPersistencePolicy.maxWindowsPerSnapshot - 1))) #if DEBUG @@ -3295,9 +3340,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent object: nil, queue: .main ) { [weak self] _ in + // Set synchronously so SwiftUI's WindowGroup teardown during logout + // sees isTerminatingApp=true and skips the unregister-time + // removeWhenEmpty save that would otherwise wipe the snapshot. + self?.isTerminatingApp = true Task { @MainActor [weak self] in guard let self else { return } - self.isTerminatingApp = true _ = self.saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) } } @@ -3464,7 +3512,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } - let persistedGeometryData = snapshot.windows.first.flatMap { primaryWindow in + let persistedGeometryData = snapshot.windows.first(where: { $0.isQuickTerminal != true }).flatMap { primaryWindow in Self.encodedPersistedWindowGeometryData( frame: primaryWindow.frame, display: primaryWindow.display @@ -3528,6 +3576,42 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent !isTerminatingApp } + /// A snapshot is "trivial" when it looks indistinguishable from a fresh + /// launch: at most one window, with at most one workspace, with no custom + /// state. We use this as the no-overwrite shield so a hard crash that + /// briefly leaves cmux in default state can't wipe a richer prior session. + nonisolated static func isTrivialSnapshot(_ snapshot: AppSessionSnapshot?) -> Bool { + guard let snapshot else { return true } + guard snapshot.windows.count <= 1 else { return false } + guard let window = snapshot.windows.first else { return true } + return isTrivialWindow(window) + } + + nonisolated static func isTrivialWindow(_ window: SessionWindowSnapshot) -> Bool { + let workspaces = window.tabManager.workspaces + guard workspaces.count <= 1 else { return false } + guard let workspace = workspaces.first else { return true } + return isTrivialWorkspace(workspace) + } + + nonisolated static func isTrivialWorkspace(_ workspace: SessionWorkspaceSnapshot) -> Bool { + if workspace.panels.count > 1 { return false } + if let title = workspace.customTitle, !title.isEmpty { return false } + if workspace.customColor != nil { return false } + if workspace.isPinned { return false } + if !workspace.statusEntries.isEmpty { return false } + if !workspace.logEntries.isEmpty { return false } + if workspace.progress != nil { return false } + if workspace.gitBranch != nil { return false } + return true + } + + nonisolated static func shouldRemoveSnapshotWhenNoWindowsRemainOnWindowUnregister( + isTerminatingApp: Bool + ) -> Bool { + !isTerminatingApp + } + nonisolated static func shouldSkipSessionSaveDuringRestore( isApplyingSessionRestore: Bool, includeScrollback: Bool @@ -3713,6 +3797,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) { guard snapshot != nil || removeWhenEmpty || persistedGeometryData != nil else { return } + let willPersistSnapshot = snapshot != nil || removeWhenEmpty + let newIsTrivial = Self.isTrivialSnapshot(snapshot) + let blockSnapshotPersist = willPersistSnapshot + && newIsTrivial + && lastPersistedSnapshotIsNonTrivial + +#if DEBUG + if blockSnapshotPersist { + dlog("session.save.skipped reason=trivial_overwrite_protect removeWhenEmpty=\(removeWhenEmpty ? 1 : 0)") + } +#endif + let writeBlock = { Self.removeLegacyPersistedWindowGeometry() if let persistedGeometryData { @@ -3721,6 +3817,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent forKey: Self.persistedWindowGeometryDefaultsKey ) } + if blockSnapshotPersist { return } if let snapshot { _ = SessionPersistenceStore.save(snapshot) } else if removeWhenEmpty { @@ -3733,6 +3830,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } else { sessionPersistenceQueue.async(execute: writeBlock) } + + if willPersistSnapshot && !blockSnapshotPersist { + lastPersistedSnapshotIsNonTrivial = !newIsTrivial + } } private func sortedMainWindowContextsForSessionSnapshot() -> [MainWindowContext] { @@ -3761,6 +3862,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent .prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) .map { context in let window = context.window ?? windowForMainWindowId(context.windowId) + let isQuickTerminal = context.windowId == QuickTerminalController.shared.windowId return SessionWindowSnapshot( frame: window.map { SessionRectSnapshot($0.frame) }, display: displaySnapshot(for: window), @@ -3772,18 +3874,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent isVisible: context.sidebarState.isVisible, selection: SessionSidebarSelection(selection: context.sidebarSelectionState.selection), width: SessionPersistencePolicy.sanitizedSidebarWidth(Double(context.sidebarState.persistedWidth)) - ) + ), + isQuickTerminal: isQuickTerminal ? true : nil ) } - guard !windows.isEmpty else { return nil } + let windowsWithPendingQuickTerminal = Self.includingPendingQuickTerminalSnapshot( + windows, + pendingQuickTerminalSnapshot: QuickTerminalController.shared.pendingSessionSnapshotForPersistence() + ) + + guard !windowsWithPendingQuickTerminal.isEmpty else { return nil } return AppSessionSnapshot( version: SessionSnapshotSchema.currentVersion, createdAt: Date().timeIntervalSince1970, - windows: windows + windows: windowsWithPendingQuickTerminal ) } + nonisolated static func includingPendingQuickTerminalSnapshot( + _ windows: [SessionWindowSnapshot], + pendingQuickTerminalSnapshot: SessionWindowSnapshot? + ) -> [SessionWindowSnapshot] { + guard let pendingQuickTerminalSnapshot else { return windows } + guard !windows.contains(where: { $0.isQuickTerminal == true }) else { return windows } + + var merged = windows + if merged.count >= SessionPersistencePolicy.maxWindowsPerSnapshot { + merged.removeLast() + } + merged.append(pendingQuickTerminalSnapshot) + return merged + } + #if DEBUG private func debugLogSessionSaveSnapshot( _ snapshot: AppSessionSnapshot, @@ -10823,6 +10946,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + private func installQuickTerminal() { + let controller = QuickTerminalController.shared + controller.loadConfiguration() + controller.installGlobalHotkey() + } + private func installGhosttyConfigObserver() { guard ghosttyConfigObserver == nil else { return } ghosttyConfigObserver = NotificationCenter.default.addObserver( diff --git a/Sources/Auth/AuthManager.swift b/Sources/Auth/AuthManager.swift index 842940a2940..97fb58a3bd7 100644 --- a/Sources/Auth/AuthManager.swift +++ b/Sources/Auth/AuthManager.swift @@ -141,12 +141,9 @@ final class AuthManager: ObservableObject { static let shared = AuthManager(tokenStore: AuthManager.defaultTokenStore()) private static func defaultTokenStore() -> any StackAuthTokenStoreProtocol { - // Release builds include a keychain-access-groups entitlement (via - // Resources/cmux.entitlements) and go through the data-protection - // keychain. Debug ad-hoc builds can't carry that entitlement - // without a provisioning profile, so Keychain writes fail with - // errSecMissingEntitlement and the file store takes over. The - // wrapper picks per-run based on the first keychain write result. + // Ad-hoc-signed builds (Debug and this branch's Release) can't carry a + // keychain-access-groups entitlement, so Keychain writes fail with + // errSecMissingEntitlement and the file store takes over. return FallbackTokenStore( primary: KeychainStackTokenStore(), fallback: FileStackTokenStore() diff --git a/Sources/GhosttyConfig.swift b/Sources/GhosttyConfig.swift index e09c56bb3fb..7363748d16d 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -25,6 +25,12 @@ struct GhosttyConfig { var unfocusedSplitFill: NSColor? var splitDividerColor: NSColor? + // Quick terminal settings + var quickTerminalKeybindRaw: String? + var quickTerminalPosition: String? + var quickTerminalAnimationDuration: Double? + var quickTerminalScreenFraction: Double? + // Colors (from theme or config) var backgroundColor: NSColor = NSColor(hex: "#272822")! var backgroundOpacity: Double = 1.0 @@ -445,6 +451,22 @@ struct GhosttyConfig { if let opacity = Double(value) { sidebarTintOpacity = min(max(opacity, 0), 1) } + case "keybind": + let action = value.split(separator: "=", maxSplits: 1).last? + .trimmingCharacters(in: .whitespaces) + if action == "toggle_quick_terminal" { + quickTerminalKeybindRaw = value + } + case "quick-terminal-position": + quickTerminalPosition = value + case "quick-terminal-animation-duration": + if let dur = Double(value) { + quickTerminalAnimationDuration = dur + } + case "quick-terminal-screen-fraction": + if let frac = Double(value) { + quickTerminalScreenFraction = frac + } default: break } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 2e6fcd46e7d..16e1b99e357 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4216,6 +4216,11 @@ class GhosttyApp { } return true } + case GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL: + return performOnMain { + QuickTerminalController.shared.toggle() + return true + } default: return false } diff --git a/Sources/QuickTerminalController.swift b/Sources/QuickTerminalController.swift new file mode 100644 index 00000000000..f95b30ec0d0 --- /dev/null +++ b/Sources/QuickTerminalController.swift @@ -0,0 +1,591 @@ +import AppKit +import Carbon +import SwiftUI + +/// Manages the quick terminal (quake/visor mode) window. +/// +/// The quick terminal is a floating, borderless window that slides down from +/// the top of the screen (or another configured edge) when toggled via a global +/// hotkey. Unlike Ghostty's quick terminal, cmux's version hosts a full +/// ContentView with sidebar and tabs. +@MainActor +final class QuickTerminalController: NSObject, NSWindowDelegate { + + // MARK: - Types + + enum Position: String, Sendable { + case top + case bottom + case left + case right + case center + } + + struct QuickTerminalKeybind: Equatable, Sendable { + var keyCode: UInt16? + var characters: String? + var commandModifier: Bool + var shiftModifier: Bool + var optionModifier: Bool + var controlModifier: Bool + + var modifierFlags: NSEvent.ModifierFlags { + var flags: NSEvent.ModifierFlags = [] + if commandModifier { flags.insert(.command) } + if shiftModifier { flags.insert(.shift) } + if optionModifier { flags.insert(.option) } + if controlModifier { flags.insert(.control) } + return flags + } + } + + // MARK: - Configuration + + private(set) var position: Position = .top + private(set) var animationDuration: TimeInterval = 0.15 + private(set) var screenFraction: CGFloat = 0.5 + + // MARK: - State + + private var window: NSWindow? + private var tabManager: TabManager? + private var sidebarState: SidebarState? + private var sidebarSelectionState: SidebarSelectionState? + private var fileExplorerState: FileExplorerState? + private var cmuxConfigStore: CmuxConfigStore? + private var isVisible = false + private var isAnimating = false + private var lastFrame: NSRect? + private(set) var windowId: UUID? + + // MARK: - Global Hotkey + + private var carbonHotkeyRef: EventHotKeyRef? + private var carbonHandlerRef: EventHandlerRef? + private var localMonitor: Any? + + /// The keybind parsed from Ghostty config (e.g. "super+grave_accent"). + /// When nil, the quick terminal feature is disabled. + private(set) var keybind: QuickTerminalKeybind? + + // MARK: - Singleton + + static let shared = QuickTerminalController() + private override init() { + super.init() + } + + // MARK: - Setup + + private var didLoadConfiguration = false + + /// Load quick terminal settings from the Ghostty config files via + /// the shared `GhosttyConfig` parser (same file search order and + /// parsing logic used for all other Ghostty settings in cmux). + func loadConfiguration() { + guard !didLoadConfiguration else { return } + didLoadConfiguration = true + + let config = GhosttyConfig.load(useCache: false) + + if let raw = config.quickTerminalKeybindRaw { + keybind = Self.parseKeybind(raw) + } + if let p = config.quickTerminalPosition, let pos = Position(rawValue: p) { + position = pos + } + if let d = config.quickTerminalAnimationDuration { + animationDuration = d + } + if let f = config.quickTerminalScreenFraction { + screenFraction = max(0.1, min(1.0, CGFloat(f))) + } + } + + /// Parse a Ghostty keybind line like "global:super+grave_accent=toggle_quick_terminal". + nonisolated static func parseKeybind(_ value: String) -> QuickTerminalKeybind? { + // Must end with toggle_quick_terminal action + let bindParts = value.split(separator: "=", maxSplits: 1) + guard bindParts.count == 2 else { return nil } + let action = bindParts[1].trimmingCharacters(in: .whitespaces) + guard action == "toggle_quick_terminal" else { return nil } + + var keyPart = bindParts[0].trimmingCharacters(in: .whitespaces) + + // Must be a global keybind + guard keyPart.hasPrefix("global:") else { return nil } + keyPart = String(keyPart.dropFirst("global:".count)) + + // Parse modifier+key combinations + let tokens = keyPart.split(separator: "+").map { $0.trimmingCharacters(in: .whitespaces).lowercased() } + guard !tokens.isEmpty else { return nil } + + var hasCommand = false + var hasShift = false + var hasOption = false + var hasControl = false + var keyName: String? + + for token in tokens { + switch token { + case "super", "cmd", "command": + hasCommand = true + case "shift": + hasShift = true + case "alt", "opt", "option": + hasOption = true + case "ctrl", "control": + hasControl = true + default: + keyName = token + } + } + + guard let key = keyName else { return nil } + + return QuickTerminalKeybind( + keyCode: ghosttyKeyNameToKeyCode(key), + characters: ghosttyKeyNameToCharacters(key), + commandModifier: hasCommand, + shiftModifier: hasShift, + optionModifier: hasOption, + controlModifier: hasControl + ) + } + + nonisolated private static func ghosttyKeyNameToKeyCode(_ name: String) -> UInt16? { + switch name { + case "grave_accent", "backquote", "`": return 50 + case "a": return 0 + case "b": return 11 + case "c": return 8 + case "d": return 2 + case "e": return 14 + case "f": return 3 + case "g": return 5 + case "h": return 4 + case "i": return 34 + case "j": return 38 + case "k": return 40 + case "l": return 37 + case "m": return 46 + case "n": return 45 + case "o": return 31 + case "p": return 35 + case "q": return 12 + case "r": return 15 + case "s": return 1 + case "t": return 17 + case "u": return 32 + case "v": return 9 + case "w": return 13 + case "x": return 7 + case "y": return 16 + case "z": return 6 + case "space": return 49 + case "tab": return 48 + case "return", "enter": return 36 + case "escape": return 53 + case "minus", "-": return 27 + case "equal", "=": return 24 + case "left_bracket", "[": return 33 + case "right_bracket", "]": return 30 + case "backslash", "\\": return 42 + case "semicolon", ";": return 41 + case "apostrophe", "'": return 39 + case "comma", ",": return 43 + case "period", ".": return 47 + case "slash", "/": return 44 + default: return nil + } + } + + nonisolated private static func ghosttyKeyNameToCharacters(_ name: String) -> String? { + switch name { + case "grave_accent", "backquote", "`": return "`" + case "space": return " " + case "tab": return "\t" + case "return", "enter": return "\r" + default: + if name.count == 1 { return name } + return nil + } + } + + // MARK: - Event Matching (nonisolated for use in monitor callbacks) + + nonisolated private static func eventMatchesKeybind(_ event: NSEvent, _ bind: QuickTerminalKeybind) -> Bool { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + + guard flags == bind.modifierFlags else { return false } + + if let expectedCode = bind.keyCode { + return event.keyCode == expectedCode + } + if let expectedChars = bind.characters { + return event.charactersIgnoringModifiers?.lowercased() == expectedChars + } + return false + } + + // MARK: - Global Hotkey Management + + /// Carbon modifier flags from our keybind. + nonisolated private static func carbonModifiers(for bind: QuickTerminalKeybind) -> UInt32 { + var mods: UInt32 = 0 + if bind.commandModifier { mods |= UInt32(cmdKey) } + if bind.shiftModifier { mods |= UInt32(shiftKey) } + if bind.optionModifier { mods |= UInt32(optionKey) } + if bind.controlModifier { mods |= UInt32(controlKey) } + return mods + } + + func installGlobalHotkey() { + guard let bind = keybind, let keyCode = bind.keyCode else { return } + + // Register a Carbon global hotkey — works system-wide without Accessibility permissions. + let hotkeyID = EventHotKeyID(signature: OSType(0x636D7578), id: 1) // "cmux" + let modifiers = Self.carbonModifiers(for: bind) + + var eventType = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)) + let handlerCallback: EventHandlerUPP = { _, event, _ -> OSStatus in + DispatchQueue.main.async { + QuickTerminalController.shared.toggle() + } + return noErr + } + + var handlerRef: EventHandlerRef? + InstallEventHandler( + GetApplicationEventTarget(), + handlerCallback, + 1, + &eventType, + nil, + &handlerRef + ) + carbonHandlerRef = handlerRef + + var hotkeyRef: EventHotKeyRef? + RegisterEventHotKey( + UInt32(keyCode), + modifiers, + hotkeyID, + GetApplicationEventTarget(), + 0, + &hotkeyRef + ) + carbonHotkeyRef = hotkeyRef + + // Local monitor: catches the hotkey when the app IS active (Carbon global + // hotkeys don't fire for the owning app's own key events). + localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + if Self.eventMatchesKeybind(event, bind) { + Self.shared.toggle() + return nil + } + return event + } + } + + func removeGlobalHotkey() { + if let ref = carbonHotkeyRef { + UnregisterEventHotKey(ref) + carbonHotkeyRef = nil + } + if let ref = carbonHandlerRef { + RemoveEventHandler(ref) + carbonHandlerRef = nil + } + if let monitor = localMonitor { + NSEvent.removeMonitor(monitor) + localMonitor = nil + } + } + + // MARK: - Toggle + + func toggle() { + guard !isAnimating else { return } + + if isVisible { + hide() + } else { + show() + } + } + + // MARK: - Show + + private func show() { + let win = window ?? createQuickTerminalWindow() + window = win + + guard let screen = NSScreen.main ?? NSScreen.screens.first else { return } + let visibleFrame = screen.visibleFrame + let targetFrame = lastFrame ?? quickTerminalFrame(in: visibleFrame) + + // Set initial off-screen frame for slide animation + var startFrame = targetFrame + switch position { + case .top: + startFrame.origin.y = visibleFrame.maxY + case .bottom: + startFrame.origin.y = visibleFrame.minY - targetFrame.height + case .left: + startFrame.origin.x = visibleFrame.minX - targetFrame.width + case .right: + startFrame.origin.x = visibleFrame.maxX + case .center: + break + } + + win.setFrame(startFrame, display: false) + win.alphaValue = position == .center ? 0 : 1 + win.orderFrontRegardless() + NSApp.activate(ignoringOtherApps: true) + win.makeKey() + + isAnimating = true + NSAnimationContext.runAnimationGroup({ context in + context.duration = animationDuration + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + win.animator().setFrame(targetFrame, display: true) + if position == .center { + win.animator().alphaValue = 1 + } + }, completionHandler: { [weak self] in + self?.isAnimating = false + self?.isVisible = true + }) + } + + // MARK: - Hide + + private func hide() { + guard let win = window else { return } + guard let screen = NSScreen.main ?? NSScreen.screens.first else { return } + let visibleFrame = screen.visibleFrame + let currentFrame = win.frame + lastFrame = currentFrame + + var endFrame = currentFrame + switch position { + case .top: + endFrame.origin.y = visibleFrame.maxY + case .bottom: + endFrame.origin.y = visibleFrame.minY - currentFrame.height + case .left: + endFrame.origin.x = visibleFrame.minX - currentFrame.width + case .right: + endFrame.origin.x = visibleFrame.maxX + case .center: + break + } + + isAnimating = true + // Use a slightly faster hide than show for snappier feel + let hideDuration = animationDuration * 0.7 + NSAnimationContext.runAnimationGroup({ context in + context.duration = hideDuration + context.timingFunction = CAMediaTimingFunction(name: .easeIn) + win.animator().setFrame(endFrame, display: true) + win.animator().alphaValue = 0 + }, completionHandler: { [weak self] in + win.orderOut(nil) + win.alphaValue = 1 // reset for next show + self?.isAnimating = false + self?.isVisible = false + + // Give focus back to the previously active app. Check if cmux + // has any other visible main windows; if not, deactivate entirely. + DispatchQueue.main.async { + let hasOtherVisibleWindow = NSApp.windows.contains { w in + w !== win && w.isVisible && !w.isMiniaturized + && w.windowNumber > 0 + && (w.styleMask.contains(.titled) || w.styleMask.contains(.fullSizeContentView)) + } + if !hasOtherVisibleWindow { + NSApp.hide(nil) + } + } + }) + } + + // MARK: - NSWindowDelegate + + nonisolated func windowShouldClose(_ sender: NSWindow) -> Bool { + MainActor.assumeIsolated { + if isVisible, !isAnimating { + hide() + } + } + return false + } + + // MARK: - Window Creation + + private func createQuickTerminalWindow() -> NSWindow { + let manager = TabManager() + self.tabManager = manager + + let sidebarState = SidebarState(isVisible: true) + self.sidebarState = sidebarState + let sidebarSelectionState = SidebarSelectionState(selection: .tabs) + self.sidebarSelectionState = sidebarSelectionState + let notificationStore = TerminalNotificationStore.shared + let fileExplorerState = FileExplorerState() + self.fileExplorerState = fileExplorerState + let cmuxConfigStore = CmuxConfigStore() + cmuxConfigStore.wireDirectoryTracking(tabManager: manager) + cmuxConfigStore.loadAll() + self.cmuxConfigStore = cmuxConfigStore + + // Apply pending session snapshot BEFORE creating the ContentView so + // SwiftUI initializes with the restored workspaces, not empty state. + if let snapshot = pendingSessionSnapshot { + pendingSessionSnapshot = nil + manager.restoreSessionSnapshot(snapshot.tabManager) + sidebarState.isVisible = snapshot.sidebar.isVisible + sidebarState.persistedWidth = CGFloat( + SessionPersistencePolicy.sanitizedSidebarWidth(snapshot.sidebar.width) + ) + sidebarSelectionState.selection = snapshot.sidebar.selection.sidebarSelection + } + + let wId = UUID() + self.windowId = wId + + let root = ContentView( + updateViewModel: AppDelegate.shared?.updateViewModel ?? UpdateViewModel(), + windowId: wId + ) + .environmentObject(manager) + .environmentObject(notificationStore) + .environmentObject(sidebarState) + .environmentObject(sidebarSelectionState) + .environmentObject(fileExplorerState) + .environmentObject(cmuxConfigStore) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 400), + styleMask: [.titled, .closable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.title = String(localized: "quickTerminal.title", defaultValue: "Quick Terminal") + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.isMovableByWindowBackground = false + window.isMovable = false + window.level = .floating + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + window.hasShadow = true + window.identifier = NSUserInterfaceItemIdentifier("cmux.quickTerminal") + window.contentView = NSHostingView(rootView: root) + window.delegate = self + + // Safety net: if the window is somehow destroyed despite windowShouldClose, + // reset state so toggle() will recreate it. + NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main + ) { [weak self] _ in + guard let self else { return } + self.window = nil + self.tabManager = nil + self.sidebarState = nil + self.sidebarSelectionState = nil + self.fileExplorerState = nil + self.cmuxConfigStore = nil + self.isVisible = false + } + + // Register with AppDelegate so terminal surfaces work properly + if let appDelegate = AppDelegate.shared { + appDelegate.registerMainWindow( + window, + windowId: wId, + tabManager: manager, + sidebarState: sidebarState, + sidebarSelectionState: sidebarSelectionState, + fileExplorerState: fileExplorerState, + cmuxConfigStore: cmuxConfigStore + ) + appDelegate.applyWindowDecorations(to: window) + } + + return window + } + + // MARK: - Frame Calculation + + private func quickTerminalFrame(in visibleFrame: NSRect) -> NSRect { + switch position { + case .top: + let height = visibleFrame.height * screenFraction + return NSRect( + x: visibleFrame.minX, + y: visibleFrame.maxY - height, + width: visibleFrame.width, + height: height + ) + case .bottom: + let height = visibleFrame.height * screenFraction + return NSRect( + x: visibleFrame.minX, + y: visibleFrame.minY, + width: visibleFrame.width, + height: height + ) + case .left: + let width = visibleFrame.width * screenFraction + return NSRect( + x: visibleFrame.minX, + y: visibleFrame.minY, + width: width, + height: visibleFrame.height + ) + case .right: + let width = visibleFrame.width * screenFraction + return NSRect( + x: visibleFrame.maxX - width, + y: visibleFrame.minY, + width: width, + height: visibleFrame.height + ) + case .center: + let width = visibleFrame.width * 0.8 + let height = visibleFrame.height * 0.8 + return NSRect( + x: visibleFrame.midX - width / 2, + y: visibleFrame.midY - height / 2, + width: width, + height: height + ) + } + } + + // MARK: - Session Restore + + /// Stash a session snapshot to be applied when the visor window is first created. + private var pendingSessionSnapshot: SessionWindowSnapshot? + + /// Queue a session snapshot for deferred restore. + /// The snapshot is applied lazily in `createQuickTerminalWindow()` so we + /// don't create an AppKit window during startup restore (which would + /// re-enter `registerMainWindow` / `attemptStartupSessionRestoreIfNeeded`). + func restoreSession(_ snapshot: SessionWindowSnapshot) { + pendingSessionSnapshot = snapshot + } + + /// Return a queued quick-terminal snapshot when the visor hasn't been + /// recreated yet in the current process. Autosave must carry this forward + /// or a crash/force-quit will overwrite the last good visor state. + func pendingSessionSnapshotForPersistence() -> SessionWindowSnapshot? { + guard window == nil else { return nil } + return pendingSessionSnapshot + } +} diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 5f28ea798ac..a01df3a93ec 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -368,6 +368,7 @@ struct SessionWindowSnapshot: Codable, Sendable { var display: SessionDisplaySnapshot? var tabManager: SessionTabManagerSnapshot var sidebar: SessionSidebarSnapshot + var isQuickTerminal: Bool? } struct AppSessionSnapshot: Codable, Sendable { diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index fc5260aada5..293f8bb0986 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -553,6 +553,41 @@ final class SessionPersistenceTests: XCTestCase { ) } + func testIncludingPendingQuickTerminalSnapshotPreservesDeferredVisorState() { + let primaryWindow = makeSnapshot(version: SessionSnapshotSchema.currentVersion).windows[0] + var pendingQuickTerminal = primaryWindow + pendingQuickTerminal.isQuickTerminal = true + pendingQuickTerminal.sidebar.isVisible = false + pendingQuickTerminal.tabManager.workspaces[0].processTitle = "visor-workspace" + + let merged = AppDelegate.includingPendingQuickTerminalSnapshot( + [primaryWindow], + pendingQuickTerminalSnapshot: pendingQuickTerminal + ) + + XCTAssertEqual(merged.count, 2) + XCTAssertEqual(merged.last?.isQuickTerminal, true) + XCTAssertEqual(merged.last?.sidebar.isVisible, false) + XCTAssertEqual(merged.last?.tabManager.workspaces.first?.processTitle, "visor-workspace") + } + + func testIncludingPendingQuickTerminalSnapshotDoesNotDuplicateExistingQuickTerminal() { + let primaryWindow = makeSnapshot(version: SessionSnapshotSchema.currentVersion).windows[0] + var existingQuickTerminal = primaryWindow + existingQuickTerminal.isQuickTerminal = true + var pendingQuickTerminal = primaryWindow + pendingQuickTerminal.isQuickTerminal = true + pendingQuickTerminal.tabManager.workspaces[0].processTitle = "pending-visor" + + let merged = AppDelegate.includingPendingQuickTerminalSnapshot( + [primaryWindow, existingQuickTerminal], + pendingQuickTerminalSnapshot: pendingQuickTerminal + ) + + XCTAssertEqual(merged.count, 2) + XCTAssertEqual(merged.last?.tabManager.workspaces.first?.processTitle, primaryWindow.tabManager.workspaces.first?.processTitle) + } + func testRestoreCompletionSavePolicySkipsManualReopen() { XCTAssertTrue( AppDelegate.shouldSaveSessionSnapshotOnRestoreCompletion(