native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #116 from onevcat/feature/layout-restore

Terminal layout snapshot save & restore (#76)

authored by

Wei Wang and committed by
GitHub
aa05916f 6b23dcd5

+1942 -96
+1
AGENTS.md
··· 100 100 - Trailing commas are mandatory (enforced by `.swiftlint.yml`) 101 101 - SwiftLint runs in strict mode; never disable lint rules without permission 102 102 - Custom SwiftLint rule: `store_state_mutation_in_views` — do not mutate `store.*` directly in view files; send actions instead 103 + - Before creating a PR, run `make lint`. 103 104 104 105 ## UX Standards 105 106
-18
supacode/App/KeybindingSchema.swift
··· 133 133 var allowUserOverride: Bool 134 134 var conflictPolicy: KeybindingConflictPolicy 135 135 var defaultBinding: Keybinding? 136 - 137 - init( 138 - id: String, 139 - title: String, 140 - scope: KeybindingScope, 141 - platform: KeybindingPlatform, 142 - allowUserOverride: Bool, 143 - conflictPolicy: KeybindingConflictPolicy, 144 - defaultBinding: Keybinding? 145 - ) { 146 - self.id = id 147 - self.title = title 148 - self.scope = scope 149 - self.platform = platform 150 - self.allowUserOverride = allowUserOverride 151 - self.conflictPolicy = conflictPolicy 152 - self.defaultBinding = defaultBinding 153 - } 154 136 } 155 137 156 138 nonisolated struct KeybindingUserOverrideStore: Codable, Equatable, Sendable {
+7
supacode/App/supacodeApp.swift
··· 30 30 @MainActor 31 31 final class SupacodeAppDelegate: NSObject, NSApplicationDelegate { 32 32 var appStore: StoreOf<AppFeature>? 33 + var terminalManager: WorktreeTerminalManager? 33 34 34 35 func applicationDidFinishLaunching(_ notification: Notification) { 35 36 // Disable press-and-hold accent menu so that key repeat works in the terminal. ··· 48 49 func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { 49 50 if flag { return true } 50 51 return showMainWindow(from: sender) ? false : true 52 + } 53 + 54 + func applicationWillTerminate(_ notification: Notification) { 55 + guard appStore?.state.settings.restoreTerminalLayoutOnLaunch == true else { return } 56 + terminalManager?.persistLayoutSnapshotSync() 51 57 } 52 58 53 59 func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { ··· 164 170 appStore?.send(.requestQuit) 165 171 } 166 172 appDelegate.appStore = appStore 173 + appDelegate.terminalManager = terminalManager 167 174 SettingsWindowManager.shared.configure( 168 175 store: appStore, 169 176 ghosttyShortcuts: shortcuts,
+25 -7
supacode/Clients/Repositories/RepositoryPersistenceClient.swift
··· 109 109 discardRepositorySnapshot(at: snapshotURL) 110 110 return nil 111 111 } 112 + guard data.count <= RepositorySnapshotCachePayload.maxSnapshotFileBytes else { 113 + repositoryPersistenceLogger.warning("Repository snapshot exceeded size fuse and was reset") 114 + discardRepositorySnapshot(at: snapshotURL) 115 + return nil 116 + } 112 117 let decoder = JSONDecoder() 113 118 do { 114 119 let payload = try await MainActor.run { ··· 144 149 } 145 150 do { 146 151 try FileManager.default.createDirectory( 147 - at: SupacodePaths.baseDirectory, 152 + at: SupacodePaths.cacheDirectory, 148 153 withIntermediateDirectories: true 149 154 ) 150 155 let encoder = JSONEncoder() ··· 155 160 let data = try await MainActor.run { 156 161 try encoder.encode(payload) 157 162 } 163 + guard data.count <= RepositorySnapshotCachePayload.maxSnapshotFileBytes else { 164 + repositoryPersistenceLogger.warning("Repository snapshot exceeded size fuse and was skipped") 165 + return 166 + } 158 167 try data.write(to: snapshotURL, options: .atomic) 159 168 } catch { 160 169 repositoryPersistenceLogger.warning( ··· 206 215 } 207 216 } 208 217 209 - struct RepositorySnapshotCachePayload: Codable, Equatable, Sendable { 210 - static let currentVersion = 2 218 + nonisolated struct RepositorySnapshotCachePayload: Codable, Equatable, Sendable { 219 + nonisolated static let currentVersion = 2 220 + nonisolated static let maxSnapshotFileBytes = 2 * 1024 * 1024 221 + nonisolated static let maxRepositories = 256 222 + nonisolated static let maxWorktreesPerRepository = 512 211 223 212 224 let version: Int 213 225 let repositories: [SnapshotRepository] ··· 223 235 guard version == Self.currentVersion, !repositories.isEmpty else { 224 236 return nil 225 237 } 238 + guard repositories.count <= Self.maxRepositories else { 239 + return nil 240 + } 226 241 227 242 var restored: [Repository] = [] 228 243 restored.reserveCapacity(repositories.count) ··· 239 254 } 240 255 241 256 extension RepositorySnapshotCachePayload { 242 - struct SnapshotRepository: Codable, Equatable, Sendable { 257 + nonisolated struct SnapshotRepository: Codable, Equatable, Sendable { 243 258 let rootPath: String 244 259 let name: String 245 260 let kind: Repository.Kind ··· 258 273 guard let normalizedRootPath = normalizePath(rootPath), pathExists(normalizedRootPath) else { 259 274 return nil 260 275 } 276 + guard worktrees.count <= RepositorySnapshotCachePayload.maxWorktreesPerRepository else { 277 + return nil 278 + } 261 279 262 280 let rootURL = URL(fileURLWithPath: normalizedRootPath).standardizedFileURL 263 281 var restoredWorktrees: [Worktree] = [] ··· 286 304 } 287 305 } 288 306 289 - struct SnapshotWorktree: Codable, Equatable, Sendable { 307 + nonisolated struct SnapshotWorktree: Codable, Equatable, Sendable { 290 308 let name: String 291 309 let detail: String 292 310 let workingDirectoryPath: String ··· 320 338 } 321 339 } 322 340 323 - private func normalizePath(_ path: String) -> String? { 324 - RepositoryPathNormalizer.normalize([path]).first 341 + private nonisolated func normalizePath(_ path: String) -> String? { 342 + PathPolicy.normalizePath(path) 325 343 } 326 344 327 345 nonisolated enum RepositoryOrderNormalizer {
+3
supacode/Clients/Terminal/TerminalClient.swift
··· 26 26 case setCommandFinishedNotification(enabled: Bool, threshold: Int) 27 27 case setCanvasMode(Bool) 28 28 case setSelectedWorktreeID(Worktree.ID?) 29 + case saveLayoutSnapshot 30 + case restoreLayoutSnapshot(worktrees: [Worktree]) 29 31 } 30 32 31 33 enum Event: Equatable { ··· 39 41 case commandPaletteToggleRequested(worktreeID: Worktree.ID) 40 42 case setupScriptConsumed(worktreeID: Worktree.ID) 41 43 case fontSizeChanged(Float32?) 44 + case layoutRestored(selectedWorktreeID: Worktree.ID?) 42 45 } 43 46 } 44 47
+132
supacode/Clients/Terminal/TerminalLayoutPersistenceClient.swift
··· 1 + import ComposableArchitecture 2 + import Foundation 3 + 4 + struct TerminalLayoutPersistenceClient { 5 + var loadSnapshot: @Sendable () async -> TerminalLayoutSnapshotPayload? 6 + var saveSnapshot: @Sendable (TerminalLayoutSnapshotPayload) async -> Bool 7 + var clearSnapshot: @Sendable () async -> Bool 8 + } 9 + 10 + extension TerminalLayoutPersistenceClient: DependencyKey { 11 + static let liveValue = TerminalLayoutPersistenceClient( 12 + loadSnapshot: { 13 + loadTerminalLayoutSnapshot( 14 + at: SupacodePaths.terminalLayoutSnapshotURL, 15 + fileManager: .default 16 + ) 17 + }, 18 + saveSnapshot: { payload in 19 + saveTerminalLayoutSnapshot( 20 + payload, 21 + at: SupacodePaths.terminalLayoutSnapshotURL, 22 + cacheDirectory: SupacodePaths.cacheDirectory, 23 + fileManager: .default 24 + ) 25 + }, 26 + clearSnapshot: { 27 + discardTerminalLayoutSnapshot(at: SupacodePaths.terminalLayoutSnapshotURL, fileManager: .default) 28 + } 29 + ) 30 + 31 + static let testValue = TerminalLayoutPersistenceClient( 32 + loadSnapshot: { nil }, 33 + saveSnapshot: { _ in true }, 34 + clearSnapshot: { true } 35 + ) 36 + } 37 + 38 + extension DependencyValues { 39 + var terminalLayoutPersistence: TerminalLayoutPersistenceClient { 40 + get { self[TerminalLayoutPersistenceClient.self] } 41 + set { self[TerminalLayoutPersistenceClient.self] = newValue } 42 + } 43 + } 44 + 45 + private nonisolated let terminalLayoutPersistenceLogger = SupaLogger("TerminalLayoutPersistence") 46 + 47 + @discardableResult 48 + nonisolated func discardTerminalLayoutSnapshot( 49 + at url: URL, 50 + fileManager: FileManager 51 + ) -> Bool { 52 + let path = url.path(percentEncoded: false) 53 + guard fileManager.fileExists(atPath: path) else { 54 + return true 55 + } 56 + do { 57 + try fileManager.removeItem(at: url) 58 + return true 59 + } catch { 60 + terminalLayoutPersistenceLogger.warning( 61 + "Unable to remove terminal layout snapshot: \(error.localizedDescription)" 62 + ) 63 + return false 64 + } 65 + } 66 + 67 + nonisolated func loadTerminalLayoutSnapshot( 68 + at url: URL, 69 + fileManager: FileManager 70 + ) -> TerminalLayoutSnapshotPayload? { 71 + let path = url.path(percentEncoded: false) 72 + terminalLayoutPersistenceLogger.info("[LayoutRestore] load: path=\(path)") 73 + guard let data = try? Data(contentsOf: url) else { 74 + terminalLayoutPersistenceLogger.info("[LayoutRestore] load: file not found or unreadable") 75 + return nil 76 + } 77 + terminalLayoutPersistenceLogger.info("[LayoutRestore] load: read \(data.count) bytes") 78 + guard !data.isEmpty else { 79 + terminalLayoutPersistenceLogger.info("[LayoutRestore] load: empty file, discarding") 80 + _ = discardTerminalLayoutSnapshot(at: url, fileManager: fileManager) 81 + return nil 82 + } 83 + guard let payload = TerminalLayoutSnapshotPayload.decodeValidated(from: data) else { 84 + terminalLayoutPersistenceLogger.warning("[LayoutRestore] load: invalid payload, discarding") 85 + _ = discardTerminalLayoutSnapshot(at: url, fileManager: fileManager) 86 + return nil 87 + } 88 + terminalLayoutPersistenceLogger.info( 89 + "[LayoutRestore] load: decoded \(payload.worktrees.count) worktree(s), version=\(payload.version)" 90 + ) 91 + return payload 92 + } 93 + 94 + nonisolated func saveTerminalLayoutSnapshot( 95 + _ payload: TerminalLayoutSnapshotPayload, 96 + at snapshotURL: URL, 97 + cacheDirectory: URL, 98 + fileManager: FileManager 99 + ) -> Bool { 100 + terminalLayoutPersistenceLogger.info( 101 + "[LayoutRestore] save: \(payload.worktrees.count) worktree(s) to \(snapshotURL.path(percentEncoded: false))" 102 + ) 103 + guard payload.isValid else { 104 + terminalLayoutPersistenceLogger.warning("[LayoutRestore] save: payload is invalid, refusing to write") 105 + return false 106 + } 107 + if payload.worktrees.isEmpty { 108 + terminalLayoutPersistenceLogger.info("[LayoutRestore] save: empty payload, discarding") 109 + return discardTerminalLayoutSnapshot(at: snapshotURL, fileManager: fileManager) 110 + } 111 + do { 112 + try fileManager.createDirectory( 113 + at: cacheDirectory, 114 + withIntermediateDirectories: true 115 + ) 116 + let encoder = JSONEncoder() 117 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 118 + let data = try encoder.encode(payload) 119 + guard data.count <= TerminalLayoutSnapshotPayload.maxSnapshotFileBytes else { 120 + terminalLayoutPersistenceLogger.warning("[LayoutRestore] save: \(data.count) bytes exceeds fuse") 121 + return false 122 + } 123 + try data.write(to: snapshotURL, options: .atomic) 124 + terminalLayoutPersistenceLogger.info("[LayoutRestore] save: wrote \(data.count) bytes successfully") 125 + return true 126 + } catch { 127 + terminalLayoutPersistenceLogger.warning( 128 + "[LayoutRestore] save: write failed: \(error.localizedDescription)" 129 + ) 130 + return false 131 + } 132 + }
+5
supacode/Features/App/Models/LaunchRestoreMode.swift
··· 1 + enum LaunchRestoreMode: Equatable, Sendable { 2 + case lastFocusedWorktree 3 + case restoreLayout 4 + // case openWorktree(Worktree.ID) // future CLI support 5 + }
+92 -7
supacode/Features/App/Reducer/AppFeature.swift
··· 4 4 import PostHog 5 5 import SwiftUI 6 6 7 + private let appLogger = SupaLogger("App") 8 + 7 9 private enum CancelID { 8 10 static let periodicRefresh = "app.periodicRefresh" 9 11 } 10 12 13 + private func makeTerminalRestorableWorktrees(from repositories: [Repository]) -> [Worktree] { 14 + var worktrees: [Worktree] = [] 15 + worktrees.reserveCapacity(repositories.reduce(0) { $0 + max(1, $1.worktrees.count) }) 16 + for repository in repositories { 17 + if repository.capabilities.supportsWorktrees { 18 + worktrees.append(contentsOf: repository.worktrees) 19 + continue 20 + } 21 + if repository.capabilities.supportsRunnableFolderActions { 22 + worktrees.append( 23 + Worktree( 24 + id: repository.id, 25 + name: repository.name, 26 + detail: repository.rootURL.path(percentEncoded: false), 27 + workingDirectory: repository.rootURL, 28 + repositoryRootURL: repository.rootURL 29 + ) 30 + ) 31 + } 32 + } 33 + return worktrees 34 + } 35 + 11 36 @Reducer 12 37 struct AppFeature { 13 38 @ObservableState ··· 24 49 var runScriptStatusByWorktreeID: [Worktree.ID: Bool] = [:] 25 50 var notificationIndicatorCount: Int = 0 26 51 var lastKnownSystemNotificationsEnabled: Bool 52 + var launchRestoreMode: LaunchRestoreMode 27 53 @Presents var alert: AlertState<Alert>? 28 54 29 55 init( ··· 33 59 self.repositories = repositories 34 60 self.settings = settings 35 61 lastKnownSystemNotificationsEnabled = settings.systemNotificationsEnabled 62 + launchRestoreMode = settings.restoreTerminalLayoutOnLaunch ? .restoreLayout : .lastFocusedWorktree 36 63 } 37 64 } 38 65 ··· 88 115 let core = Reduce<State, Action> { state, action in 89 116 switch action { 90 117 case .appLaunched: 118 + try? SupacodePaths.migrateLegacyCacheFilesIfNeeded() 119 + appLogger.info("[LayoutRestore] appLaunched: launchRestoreMode=\(String(describing: state.launchRestoreMode))") 120 + state.repositories.launchRestoreMode = state.launchRestoreMode 91 121 return .merge( 92 122 .send(.repositories(.task)), 93 123 .send(.settings(.task)), ··· 124 154 .cancellable(id: CancelID.periodicRefresh, cancelInFlight: true) 125 155 ) 126 156 case .inactive, .background: 127 - return .cancel(id: CancelID.periodicRefresh) 157 + var effects: [Effect<Action>] = [.cancel(id: CancelID.periodicRefresh)] 158 + if state.settings.restoreTerminalLayoutOnLaunch { 159 + appLogger.info("[LayoutRestore] scenePhase=\(String(describing: phase)), saving layout snapshot") 160 + effects.append(.run { _ in await terminalClient.send(.saveLayoutSnapshot) }) 161 + } 162 + return .merge(effects) 128 163 @unknown default: 129 164 return .cancel(id: CancelID.periodicRefresh) 130 165 } ··· 222 257 let ids = state.repositories.terminalStateIDs.subtracting(archivedIDs) 223 258 let recencyIDs = CommandPaletteFeature.recencyRetentionIDs(from: repositories) 224 259 let worktrees = state.repositories.worktreesForInfoWatcher() 260 + let shouldRestoreLayout = 261 + state.launchRestoreMode == .restoreLayout 262 + && state.repositories.snapshotPersistencePhase == .active 263 + appLogger.info( 264 + "[LayoutRestore] repositoriesChanged: mode=\(String(describing: state.launchRestoreMode))" 265 + + " phase=\(String(describing: state.repositories.snapshotPersistencePhase))" 266 + + " → shouldRestore=\(shouldRestoreLayout)" 267 + ) 268 + if shouldRestoreLayout { 269 + state.launchRestoreMode = .lastFocusedWorktree 270 + state.repositories.selection = nil 271 + } 225 272 state.runScriptStatusByWorktreeID = state.runScriptStatusByWorktreeID.filter { ids.contains($0.key) } 273 + let restorableWorktrees = makeTerminalRestorableWorktrees(from: Array(repositories)) 274 + appLogger.info("[LayoutRestore] restorableWorktrees count=\(restorableWorktrees.count)") 226 275 if case .repository(let repositoryID)? = state.settings.selection, 227 276 !repositories.contains(where: { $0.id == repositoryID }) 228 277 { 229 - return .merge( 278 + var effects: [Effect<Action>] = [ 230 279 .send(.settings(.setSelection(.general))), 231 280 .send(.commandPalette(.pruneRecency(recencyIDs))), 232 281 .run { _ in ··· 234 283 }, 235 284 .run { _ in 236 285 await worktreeInfoWatcher.send(.setWorktrees(worktrees)) 237 - } 238 - ) 286 + }, 287 + ] 288 + if shouldRestoreLayout { 289 + effects.append( 290 + .run { _ in 291 + await terminalClient.send(.restoreLayoutSnapshot(worktrees: restorableWorktrees)) 292 + } 293 + ) 294 + } 295 + return .merge(effects) 239 296 } 240 - return .merge( 297 + var effects: [Effect<Action>] = [ 241 298 .send(.commandPalette(.pruneRecency(recencyIDs))), 242 299 .run { _ in 243 300 await terminalClient.send(.prune(ids)) 244 301 }, 245 302 .run { _ in 246 303 await worktreeInfoWatcher.send(.setWorktrees(worktrees)) 247 - } 248 - ) 304 + }, 305 + ] 306 + if shouldRestoreLayout { 307 + effects.append( 308 + .run { _ in 309 + await terminalClient.send(.restoreLayoutSnapshot(worktrees: restorableWorktrees)) 310 + } 311 + ) 312 + } 313 + return .merge(effects) 249 314 250 315 case .repositories(.delegate(.openRepositorySettings(let repositoryID))): 251 316 guard state.repositories.repositories.contains(where: { $0.id == repositoryID }) else { ··· 354 419 355 420 case .settings(.delegate(.terminalFontSizeChanged)): 356 421 return .none 422 + 423 + case .settings(.delegate(.terminalLayoutSnapshotCleared(let success))): 424 + if success { 425 + return .send(.repositories(.showToast(.success("Saved terminal layout cleared")))) 426 + } 427 + return .send( 428 + .repositories( 429 + .presentAlert( 430 + title: "Unable to clear saved terminal layout", 431 + message: "Please check file permissions and try again." 432 + ) 433 + ) 434 + ) 357 435 358 436 case .openActionSelectionChanged(let action): 359 437 state.openActionSelection = action ··· 796 874 797 875 case .terminalEvent(.fontSizeChanged(let fontSize)): 798 876 return .send(.settings(.setTerminalFontSize(fontSize))) 877 + 878 + case .terminalEvent(.layoutRestored(let selectedWorktreeID)): 879 + appLogger.info("[LayoutRestore] layoutRestored: selectedWorktreeID=\(selectedWorktreeID ?? "nil")") 880 + if let selectedWorktreeID { 881 + return .send(.repositories(.selectWorktree(selectedWorktreeID))) 882 + } 883 + return .none 799 884 800 885 case .terminalEvent: 801 886 return .none
+33 -19
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 85 85 var lastFocusedWorktreeID: Worktree.ID? 86 86 var preCanvasWorktreeID: Worktree.ID? 87 87 var preCanvasTerminalTargetID: Worktree.ID? 88 + var launchRestoreMode: LaunchRestoreMode = .lastFocusedWorktree 88 89 var shouldRestoreLastFocusedWorktree = false 89 90 var shouldSelectFirstAfterReload = false 90 91 var isRefreshingWorktrees = false 91 92 var statusToast: StatusToast? 93 + var snapshotPersistencePhase: SnapshotPersistencePhase = .idle 92 94 var githubIntegrationAvailability: GithubIntegrationAvailability = .unknown 93 95 var pendingPullRequestRefreshByRepositoryID: [Repository.ID: PendingPullRequestRefresh] = [:] 94 96 var inFlightPullRequestRefreshRepositoryIDs: Set<Repository.ID> = [] ··· 271 273 case success(String) 272 274 } 273 275 276 + enum SnapshotPersistencePhase: Equatable { 277 + case idle 278 + case restoring 279 + case active 280 + } 281 + 274 282 enum Alert: Equatable { 275 283 case confirmArchiveWorktree(Worktree.ID, Repository.ID) 276 284 case confirmArchiveWorktrees([ArchiveWorktreeTarget]) ··· 311 319 Reduce { state, action in 312 320 switch action { 313 321 case .task: 322 + state.snapshotPersistencePhase = .restoring 314 323 return .run { send in 315 324 let pinned = await repositoryPersistence.loadPinnedWorktreeIDs() 316 325 let archived = await repositoryPersistence.loadArchivedWorktreeIDs() ··· 382 391 383 392 case .lastFocusedWorktreeIDLoaded(let lastFocusedWorktreeID): 384 393 state.lastFocusedWorktreeID = lastFocusedWorktreeID 385 - state.shouldRestoreLastFocusedWorktree = true 394 + if state.launchRestoreMode != .restoreLayout { 395 + state.shouldRestoreLastFocusedWorktree = true 396 + } 386 397 return .none 387 398 388 399 case .setOpenPanelPresented(let isPresented): ··· 422 433 423 434 case .repositoriesLoaded(let repositories, let failures, let roots, let animated): 424 435 state.isRefreshingWorktrees = false 436 + let wasRestoringSnapshot = state.snapshotPersistencePhase == .restoring 437 + if failures.isEmpty, state.snapshotPersistencePhase != .active { 438 + state.snapshotPersistencePhase = .active 439 + } 425 440 let previousSelection = state.selectedWorktreeID 426 441 let previousSelectedWorktree = state.worktree(for: previousSelection) 427 442 let incomingRepositories = IdentifiedArray(uniqueElements: repositories) ··· 481 496 } 482 497 ) 483 498 } 484 - if failures.isEmpty { 499 + if failures.isEmpty, !wasRestoringSnapshot { 485 500 let repositories = Array(state.repositories) 486 501 allEffects.append( 487 502 .run { _ in ··· 553 568 let roots 554 569 ): 555 570 state.isRefreshingWorktrees = false 571 + let wasRestoringSnapshot = state.snapshotPersistencePhase == .restoring 572 + if failures.isEmpty, state.snapshotPersistencePhase != .active { 573 + state.snapshotPersistencePhase = .active 574 + } 556 575 let previousSelection = state.selectedWorktreeID 557 576 let previousSelectedWorktree = state.worktree(for: previousSelection) 558 577 let applyResult = applyRepositories( ··· 616 635 } 617 636 ) 618 637 } 619 - if failures.isEmpty { 638 + if failures.isEmpty, !wasRestoringSnapshot { 620 639 let repositories = Array(state.repositories) 621 640 allEffects.append( 622 641 .run { _ in ··· 2772 2791 } 2773 2792 2774 2793 private nonisolated static func isNotGitRepositoryError(_ error: any Error) -> Bool { 2775 - guard case let GitClientError.commandFailed(_, message) = error else { 2794 + guard case GitClientError.commandFailed(_, let message) = error else { 2776 2795 return false 2777 2796 } 2778 2797 return message.localizedCaseInsensitiveContains("not a git repository") ··· 2780 2799 2781 2800 private nonisolated static func openRepositoryFailureMessage(path: String, error: any Error) -> String { 2782 2801 let detail: String 2783 - if case let GitClientError.commandFailed(_, message) = error, 2802 + if case GitClientError.commandFailed(_, let message) = error, 2784 2803 !message.isEmpty 2785 2804 { 2786 2805 detail = message ··· 2852 2871 return WorktreesFetchResult( 2853 2872 entry: entry, 2854 2873 repository: Repository( 2855 - id: rootURL.path(percentEncoded: false), 2856 - rootURL: rootURL, 2857 - name: Repository.name(for: rootURL), 2858 - kind: .plain, 2859 - worktrees: IdentifiedArray() 2860 - ), 2861 - errorMessage: nil 2862 - ) 2874 + id: rootURL.path(percentEncoded: false), 2875 + rootURL: rootURL, 2876 + name: Repository.name(for: rootURL), 2877 + kind: .plain, 2878 + worktrees: IdentifiedArray() 2879 + ), 2880 + errorMessage: nil 2881 + ) 2863 2882 } 2864 2883 } 2865 2884 } ··· 3605 3624 } 3606 3625 3607 3626 private func isPathInsideBaseDirectory(_ path: URL, baseDirectory: URL) -> Bool { 3608 - let normalizedPath = path.standardizedFileURL.pathComponents 3609 - let normalizedBase = baseDirectory.standardizedFileURL.pathComponents 3610 - guard normalizedPath.count >= normalizedBase.count else { 3611 - return false 3612 - } 3613 - return Array(normalizedPath.prefix(normalizedBase.count)) == normalizedBase 3627 + PathPolicy.contains(path, in: baseDirectory) 3614 3628 } 3615 3629 3616 3630 private struct WorktreeCleanupStateResult {
+2 -6
supacode/Features/Settings/BusinessLogic/RepositoryPersistenceKeys.swift
··· 1 - import Foundation 2 1 import Dependencies 2 + import Foundation 3 3 import Sharing 4 4 5 5 nonisolated struct RepositoryEntriesKeyID: Hashable, Sendable {} ··· 178 178 var normalized: [String] = [] 179 179 normalized.reserveCapacity(paths.count) 180 180 for path in paths { 181 - let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) 182 - guard !trimmed.isEmpty else { continue } 183 - let resolved = URL(fileURLWithPath: trimmed) 184 - .standardizedFileURL 185 - .path(percentEncoded: false) 181 + guard let resolved = PathPolicy.normalizePath(path, resolvingSymlinks: false) else { continue } 186 182 if seen.insert(resolved).inserted { 187 183 normalized.append(resolved) 188 184 }
+7
supacode/Features/Settings/Models/GlobalSettings.swift
··· 18 18 var automaticallyArchiveMergedWorktrees: Bool 19 19 var promptForWorktreeCreation: Bool 20 20 var defaultWorktreeBaseDirectoryPath: String? 21 + var restoreTerminalLayoutOnLaunch: Bool 21 22 var terminalFontSize: Float32? 22 23 23 24 static let `default` = GlobalSettings( ··· 40 41 automaticallyArchiveMergedWorktrees: false, 41 42 promptForWorktreeCreation: true, 42 43 defaultWorktreeBaseDirectoryPath: nil, 44 + restoreTerminalLayoutOnLaunch: false, 43 45 terminalFontSize: nil 44 46 ) 45 47 ··· 63 65 automaticallyArchiveMergedWorktrees: Bool, 64 66 promptForWorktreeCreation: Bool, 65 67 defaultWorktreeBaseDirectoryPath: String? = nil, 68 + restoreTerminalLayoutOnLaunch: Bool = false, 66 69 terminalFontSize: Float32? = nil 67 70 ) { 68 71 self.appearanceMode = appearanceMode ··· 84 87 self.automaticallyArchiveMergedWorktrees = automaticallyArchiveMergedWorktrees 85 88 self.promptForWorktreeCreation = promptForWorktreeCreation 86 89 self.defaultWorktreeBaseDirectoryPath = defaultWorktreeBaseDirectoryPath 90 + self.restoreTerminalLayoutOnLaunch = restoreTerminalLayoutOnLaunch 87 91 self.terminalFontSize = terminalFontSize 88 92 } 89 93 ··· 140 144 defaultWorktreeBaseDirectoryPath = 141 145 try container.decodeIfPresent(String.self, forKey: .defaultWorktreeBaseDirectoryPath) 142 146 ?? Self.default.defaultWorktreeBaseDirectoryPath 147 + restoreTerminalLayoutOnLaunch = 148 + try container.decodeIfPresent(Bool.self, forKey: .restoreTerminalLayoutOnLaunch) 149 + ?? Self.default.restoreTerminalLayoutOnLaunch 143 150 terminalFontSize = 144 151 try container.decodeIfPresent(Float32.self, forKey: .terminalFontSize) 145 152 ?? Self.default.terminalFontSize
+13
supacode/Features/Settings/Reducer/SettingsFeature.swift
··· 24 24 var automaticallyArchiveMergedWorktrees: Bool 25 25 var promptForWorktreeCreation: Bool 26 26 var defaultWorktreeBaseDirectoryPath: String 27 + var restoreTerminalLayoutOnLaunch: Bool 27 28 var terminalFontSize: Float32? 28 29 var selection: SettingsSection? = .general 29 30 var repositorySettings: RepositorySettingsFeature.State? ··· 51 52 promptForWorktreeCreation = settings.promptForWorktreeCreation 52 53 defaultWorktreeBaseDirectoryPath = 53 54 SupacodePaths.normalizedWorktreeBaseDirectoryPath(settings.defaultWorktreeBaseDirectoryPath) ?? "" 55 + restoreTerminalLayoutOnLaunch = settings.restoreTerminalLayoutOnLaunch 54 56 terminalFontSize = settings.terminalFontSize 55 57 } 56 58 ··· 77 79 defaultWorktreeBaseDirectoryPath: SupacodePaths.normalizedWorktreeBaseDirectoryPath( 78 80 defaultWorktreeBaseDirectoryPath 79 81 ), 82 + restoreTerminalLayoutOnLaunch: restoreTerminalLayoutOnLaunch, 80 83 terminalFontSize: terminalFontSize 81 84 ) 82 85 } ··· 89 92 case setSystemNotificationsEnabled(Bool) 90 93 case setCommandFinishedNotificationThreshold(String) 91 94 case setTerminalFontSize(Float32?) 95 + case clearTerminalLayoutSnapshotButtonTapped 92 96 case showNotificationPermissionAlert(errorMessage: String?) 93 97 case repositorySettings(RepositorySettingsFeature.Action) 94 98 case alert(PresentationAction<Alert>) ··· 105 109 enum Delegate: Equatable { 106 110 case settingsChanged(GlobalSettings) 107 111 case terminalFontSizeChanged(Float32?) 112 + case terminalLayoutSnapshotCleared(success: Bool) 108 113 } 109 114 110 115 @Dependency(AnalyticsClient.self) private var analyticsClient 111 116 @Dependency(SystemNotificationClient.self) private var systemNotificationClient 117 + @Dependency(TerminalLayoutPersistenceClient.self) private var terminalLayoutPersistence 112 118 113 119 var body: some Reducer<State, Action> { 114 120 BindingReducer() ··· 154 160 state.automaticallyArchiveMergedWorktrees = normalizedSettings.automaticallyArchiveMergedWorktrees 155 161 state.promptForWorktreeCreation = normalizedSettings.promptForWorktreeCreation 156 162 state.defaultWorktreeBaseDirectoryPath = normalizedSettings.defaultWorktreeBaseDirectoryPath ?? "" 163 + state.restoreTerminalLayoutOnLaunch = normalizedSettings.restoreTerminalLayoutOnLaunch 157 164 state.terminalFontSize = normalizedSettings.terminalFontSize 158 165 state.repositorySettings?.globalDefaultWorktreeBaseDirectoryPath = 159 166 normalizedSettings.defaultWorktreeBaseDirectoryPath ··· 188 195 persist(state, captureAnalytics: false, emitSettingsChanged: false), 189 196 .send(.delegate(.terminalFontSizeChanged(fontSize))) 190 197 ) 198 + 199 + case .clearTerminalLayoutSnapshotButtonTapped: 200 + return .run { send in 201 + let success = await terminalLayoutPersistence.clearSnapshot() 202 + await send(.delegate(.terminalLayoutSnapshotCleared(success: success))) 203 + } 191 204 192 205 case .showNotificationPermissionAlert(let errorMessage): 193 206 let message: String
+17
supacode/Features/Settings/Views/AdvancedSettingsView.swift
··· 22 22 .font(.callout) 23 23 } 24 24 .frame(maxWidth: .infinity, alignment: .leading) 25 + 25 26 VStack(alignment: .leading) { 26 27 Toggle( 27 28 "Share crash reports with Prowl", ··· 34 35 Text("Requires app restart.") 35 36 .foregroundStyle(.secondary) 36 37 .font(.callout) 38 + } 39 + .frame(maxWidth: .infinity, alignment: .leading) 40 + 41 + VStack(alignment: .leading, spacing: 8) { 42 + Toggle( 43 + "Restore terminal layout on launch (experimental)", 44 + isOn: $store.restoreTerminalLayoutOnLaunch 45 + ) 46 + Text("When enabled, Prowl attempts to restore tabs and splits after restart.") 47 + .foregroundStyle(.secondary) 48 + .font(.callout) 49 + Button("Clear saved terminal layout") { 50 + store.send(.clearTerminalLayoutSnapshotButtonTapped) 51 + } 52 + .help("Remove the saved terminal tab and split layout from disk") 53 + .buttonStyle(.bordered) 37 54 } 38 55 .frame(maxWidth: .infinity, alignment: .leading) 39 56 }
+135 -1
supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift
··· 1 + import Foundation 1 2 import Observation 2 3 import Sharing 3 4 ··· 7 8 @Observable 8 9 final class WorktreeTerminalManager { 9 10 private let runtime: GhosttyRuntime 11 + private let layoutPersistence: TerminalLayoutPersistenceClient 10 12 private var states: [Worktree.ID: WorktreeTerminalState] = [:] 11 13 private var notificationsEnabled = true 12 14 private var commandFinishedNotificationEnabled = true ··· 21 23 /// Used by toggleCanvas to know which worktree to return to. 22 24 var canvasFocusedWorktreeID: Worktree.ID? 23 25 24 - init(runtime: GhosttyRuntime, preferredFontSize: Float32? = nil) { 26 + init( 27 + runtime: GhosttyRuntime, 28 + preferredFontSize: Float32? = nil, 29 + layoutPersistence: TerminalLayoutPersistenceClient = .liveValue 30 + ) { 25 31 self.runtime = runtime 32 + self.layoutPersistence = layoutPersistence 26 33 self.preferredFontSize = preferredFontSize 27 34 baselineFontSize = runtime.defaultFontSize() 28 35 } ··· 127 134 } 128 135 selectedWorktreeID = id 129 136 terminalLogger.info("Selected worktree \(id ?? "nil")") 137 + case .saveLayoutSnapshot: 138 + terminalLogger.info("[LayoutRestore] received saveLayoutSnapshot command") 139 + Task { await persistLayoutSnapshot() } 140 + case .restoreLayoutSnapshot(let worktrees): 141 + terminalLogger.info("[LayoutRestore] received restoreLayoutSnapshot command, worktrees=\(worktrees.count)") 142 + Task { await restoreLayoutSnapshot(from: worktrees) } 130 143 default: 131 144 return 132 145 } ··· 381 394 lastNotificationIndicatorCount = count 382 395 emit(.notificationIndicatorChanged(count: count)) 383 396 } 397 + } 398 + 399 + func persistLayoutSnapshot() async { 400 + guard let payload = makeLayoutSnapshotPayload() else { 401 + terminalLogger.info("[LayoutRestore] persist: no active states, clearing snapshot") 402 + _ = await layoutPersistence.clearSnapshot() 403 + return 404 + } 405 + terminalLogger.info("[LayoutRestore] persist: saving \(payload.worktrees.count) worktree(s)") 406 + let saved = await layoutPersistence.saveSnapshot(payload) 407 + terminalLogger.info("[LayoutRestore] persist: save result=\(saved)") 408 + } 409 + 410 + func persistLayoutSnapshotSync() { 411 + guard let payload = makeLayoutSnapshotPayload() else { 412 + terminalLogger.info("[LayoutRestore] persistSync: no active states, clearing snapshot") 413 + discardTerminalLayoutSnapshot(at: SupacodePaths.terminalLayoutSnapshotURL, fileManager: .default) 414 + return 415 + } 416 + terminalLogger.info("[LayoutRestore] persistSync: saving \(payload.worktrees.count) worktree(s)") 417 + let saved = saveTerminalLayoutSnapshot( 418 + payload, 419 + at: SupacodePaths.terminalLayoutSnapshotURL, 420 + cacheDirectory: SupacodePaths.cacheDirectory, 421 + fileManager: .default 422 + ) 423 + terminalLogger.info("[LayoutRestore] persistSync: save result=\(saved)") 424 + } 425 + 426 + func restoreLayoutSnapshot(from worktrees: [Worktree]) async { 427 + terminalLogger.info("[LayoutRestore] restore: loading snapshot from disk") 428 + guard let payload = await layoutPersistence.loadSnapshot() else { 429 + terminalLogger.info("[LayoutRestore] restore: no snapshot found on disk, skipping") 430 + return 431 + } 432 + terminalLogger.info( 433 + "[LayoutRestore] restore: loaded snapshot with \(payload.worktrees.count) worktree(s)," 434 + + " available worktrees=\(worktrees.count)" 435 + ) 436 + for (index, snapshot) in payload.worktrees.enumerated() { 437 + terminalLogger.info( 438 + "[LayoutRestore] restore: snapshot[\(index)] worktreeID=\(snapshot.worktreeID)" 439 + + " tabs=\(snapshot.tabs.count) selectedTab=\(snapshot.selectedTabID ?? "nil")" 440 + ) 441 + } 442 + for (index, worktree) in worktrees.enumerated() { 443 + terminalLogger.info("[LayoutRestore] restore: available[\(index)] id=\(worktree.id) name=\(worktree.name)") 444 + } 445 + let didRestore = applyLayoutSnapshotPayload(payload, availableWorktrees: worktrees) 446 + terminalLogger.info("[LayoutRestore] restore: applyResult=\(didRestore)") 447 + if didRestore { 448 + terminalLogger.info( 449 + "[LayoutRestore] restore: emitting layoutRestored selectedWorktreeID=\(payload.selectedWorktreeID ?? "nil")" 450 + ) 451 + emit(.layoutRestored(selectedWorktreeID: payload.selectedWorktreeID)) 452 + } else { 453 + terminalLogger.info("[LayoutRestore] restore: clearing invalid snapshot") 454 + _ = await layoutPersistence.clearSnapshot() 455 + } 456 + } 457 + 458 + private func makeLayoutSnapshotPayload() -> TerminalLayoutSnapshotPayload? { 459 + let activeStates = activeWorktreeStates.sorted { $0.worktreeID < $1.worktreeID } 460 + terminalLogger.info( 461 + "[LayoutRestore] makePayload: activeWorktreeStates=\(activeStates.count)" 462 + + " totalStates=\(states.count)" 463 + ) 464 + guard !activeStates.isEmpty else { 465 + return nil 466 + } 467 + 468 + var snapshotWorktrees: [TerminalLayoutSnapshotPayload.SnapshotWorktree] = [] 469 + snapshotWorktrees.reserveCapacity(activeStates.count) 470 + for state in activeStates { 471 + guard let snapshot = state.makeLayoutSnapshotWorktree() else { 472 + terminalLogger.warning( 473 + "[LayoutRestore] makePayload: failed to snapshot worktree \(state.worktreeID)" 474 + ) 475 + return nil 476 + } 477 + snapshotWorktrees.append(snapshot) 478 + } 479 + return TerminalLayoutSnapshotPayload( 480 + selectedWorktreeID: selectedWorktreeID, 481 + worktrees: snapshotWorktrees 482 + ) 483 + } 484 + 485 + private func applyLayoutSnapshotPayload( 486 + _ payload: TerminalLayoutSnapshotPayload, 487 + availableWorktrees: [Worktree] 488 + ) -> Bool { 489 + let worktreeByID = Dictionary(uniqueKeysWithValues: availableWorktrees.map { ($0.id, $0) }) 490 + var restoredStates: [WorktreeTerminalState] = [] 491 + restoredStates.reserveCapacity(payload.worktrees.count) 492 + 493 + for snapshot in payload.worktrees { 494 + guard let worktree = worktreeByID[snapshot.worktreeID] else { 495 + terminalLogger.warning( 496 + "[LayoutRestore] apply: worktreeID \(snapshot.worktreeID) not found in available worktrees" 497 + ) 498 + for state in restoredStates { 499 + state.closeAllSurfaces() 500 + } 501 + return false 502 + } 503 + terminalLogger.info("[LayoutRestore] apply: restoring worktree \(worktree.id)") 504 + let state = state(for: worktree) 505 + guard state.applyLayoutSnapshot(snapshot) else { 506 + terminalLogger.warning("[LayoutRestore] apply: applyLayoutSnapshot failed for \(worktree.id)") 507 + state.closeAllSurfaces() 508 + for restored in restoredStates { 509 + restored.closeAllSurfaces() 510 + } 511 + return false 512 + } 513 + restoredStates.append(state) 514 + } 515 + 516 + terminalLogger.info("[LayoutRestore] apply: successfully restored \(restoredStates.count) worktree(s)") 517 + return true 384 518 } 385 519 }
+4
supacode/Features/Terminal/Models/SplitTree.swift
··· 312 312 self.root = root 313 313 self.zoomed = zoomed 314 314 } 315 + 316 + static func restored(root: Node) -> SplitTree { 317 + SplitTree(root: root, zoomed: nil) 318 + } 315 319 } 316 320 317 321 extension SplitTree.Node {
+218
supacode/Features/Terminal/Models/TerminalLayoutSnapshotPayload.swift
··· 1 + import Foundation 2 + 3 + nonisolated struct TerminalLayoutSnapshotPayload: Codable, Equatable, Sendable { 4 + nonisolated static let currentVersion = 1 5 + nonisolated static let maxSnapshotFileBytes = 2 * 1024 * 1024 6 + nonisolated static let maxWorktrees = 128 7 + nonisolated static let maxTabsPerWorktree = 128 8 + nonisolated static let maxSplitNodesPerTab = 1024 9 + nonisolated static let maxSplitDepth = 24 10 + 11 + let version: Int 12 + let selectedWorktreeID: String? 13 + let worktrees: [SnapshotWorktree] 14 + 15 + init(selectedWorktreeID: String? = nil, worktrees: [SnapshotWorktree]) { 16 + version = Self.currentVersion 17 + self.selectedWorktreeID = selectedWorktreeID 18 + self.worktrees = worktrees 19 + } 20 + 21 + init(version: Int, selectedWorktreeID: String? = nil, worktrees: [SnapshotWorktree]) { 22 + self.version = version 23 + self.selectedWorktreeID = selectedWorktreeID 24 + self.worktrees = worktrees 25 + } 26 + 27 + static func decodeValidated(from data: Data) -> TerminalLayoutSnapshotPayload? { 28 + guard !data.isEmpty, data.count <= Self.maxSnapshotFileBytes else { 29 + return nil 30 + } 31 + let decoder = JSONDecoder() 32 + guard let payload = try? decoder.decode(Self.self, from: data) else { 33 + return nil 34 + } 35 + return payload.isValid ? payload : nil 36 + } 37 + 38 + var isValid: Bool { 39 + guard version == Self.currentVersion else { 40 + return false 41 + } 42 + guard !worktrees.isEmpty, worktrees.count <= Self.maxWorktrees else { 43 + return false 44 + } 45 + guard worktrees.allSatisfy({ 46 + $0.isValid( 47 + maxTabsPerWorktree: Self.maxTabsPerWorktree, 48 + maxSplitNodesPerTab: Self.maxSplitNodesPerTab, 49 + maxSplitDepth: Self.maxSplitDepth 50 + ) 51 + }) else { 52 + return false 53 + } 54 + if let selectedWorktreeID { 55 + guard worktrees.contains(where: { $0.worktreeID == selectedWorktreeID }) else { 56 + return false 57 + } 58 + } 59 + return true 60 + } 61 + } 62 + 63 + extension TerminalLayoutSnapshotPayload { 64 + nonisolated struct SnapshotWorktree: Codable, Equatable, Sendable { 65 + let worktreeID: String 66 + let selectedTabID: String? 67 + let tabs: [SnapshotTab] 68 + 69 + func isValid( 70 + maxTabsPerWorktree: Int, 71 + maxSplitNodesPerTab: Int, 72 + maxSplitDepth: Int 73 + ) -> Bool { 74 + guard hasContent(worktreeID) else { 75 + return false 76 + } 77 + guard !tabs.isEmpty, tabs.count <= maxTabsPerWorktree else { 78 + return false 79 + } 80 + 81 + var tabIDs: Set<String> = [] 82 + for tab in tabs { 83 + guard tabIDs.insert(tab.tabID).inserted else { 84 + return false 85 + } 86 + guard tab.isValid(maxSplitNodesPerTab: maxSplitNodesPerTab, maxSplitDepth: maxSplitDepth) else { 87 + return false 88 + } 89 + } 90 + 91 + if let selectedTabID { 92 + return tabIDs.contains(selectedTabID) 93 + } 94 + return true 95 + } 96 + } 97 + 98 + nonisolated struct SnapshotTab: Codable, Equatable, Sendable { 99 + let tabID: String 100 + let splitRoot: SnapshotSplitNode 101 + 102 + func isValid(maxSplitNodesPerTab: Int, maxSplitDepth: Int) -> Bool { 103 + guard hasContent(tabID) else { 104 + return false 105 + } 106 + var nodeCount = 0 107 + return splitRoot.isValid( 108 + depth: 1, 109 + maxDepth: maxSplitDepth, 110 + nodeCount: &nodeCount, 111 + maxNodes: maxSplitNodesPerTab 112 + ) 113 + } 114 + } 115 + 116 + nonisolated struct SnapshotSplitNode: Codable, Equatable, Sendable { 117 + let kind: TerminalLayoutSnapshotNodeKind 118 + let surfaceID: String? 119 + let cwdPath: String? 120 + let direction: TerminalLayoutSnapshotSplitDirection? 121 + let ratio: Double? 122 + let children: [SnapshotSplitNode]? 123 + 124 + static func leaf(surfaceID: String, cwdPath: String? = nil) -> SnapshotSplitNode { 125 + SnapshotSplitNode( 126 + kind: .leaf, 127 + surfaceID: surfaceID, 128 + cwdPath: cwdPath, 129 + direction: nil, 130 + ratio: nil, 131 + children: nil 132 + ) 133 + } 134 + 135 + static func split( 136 + direction: TerminalLayoutSnapshotSplitDirection, 137 + ratio: Double, 138 + children: [SnapshotSplitNode] 139 + ) -> SnapshotSplitNode { 140 + SnapshotSplitNode( 141 + kind: .split, 142 + surfaceID: nil, 143 + cwdPath: nil, 144 + direction: direction, 145 + ratio: ratio, 146 + children: children 147 + ) 148 + } 149 + 150 + func isValid( 151 + depth: Int, 152 + maxDepth: Int, 153 + nodeCount: inout Int, 154 + maxNodes: Int 155 + ) -> Bool { 156 + nodeCount += 1 157 + guard nodeCount <= maxNodes else { 158 + return false 159 + } 160 + 161 + switch kind { 162 + case .leaf: 163 + guard hasContent(surfaceID) else { 164 + return false 165 + } 166 + if let cwdPath, !hasContent(cwdPath) { 167 + return false 168 + } 169 + guard direction == nil, ratio == nil else { 170 + return false 171 + } 172 + return children == nil || children?.isEmpty == true 173 + 174 + case .split: 175 + guard depth < maxDepth else { 176 + return false 177 + } 178 + guard surfaceID == nil, cwdPath == nil else { 179 + return false 180 + } 181 + guard direction != nil else { 182 + return false 183 + } 184 + guard let ratio, ratio > 0, ratio < 1 else { 185 + return false 186 + } 187 + guard let children, children.count == 2 else { 188 + return false 189 + } 190 + return children.allSatisfy { 191 + $0.isValid( 192 + depth: depth + 1, 193 + maxDepth: maxDepth, 194 + nodeCount: &nodeCount, 195 + maxNodes: maxNodes 196 + ) 197 + } 198 + } 199 + } 200 + } 201 + } 202 + 203 + nonisolated enum TerminalLayoutSnapshotNodeKind: String, Codable, Sendable { 204 + case leaf 205 + case split 206 + } 207 + 208 + nonisolated enum TerminalLayoutSnapshotSplitDirection: String, Codable, Sendable { 209 + case horizontal 210 + case vertical 211 + } 212 + 213 + private nonisolated func hasContent(_ value: String?) -> Bool { 214 + guard let value else { 215 + return false 216 + } 217 + return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty 218 + }
+267 -1
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 5 5 import Observation 6 6 import Sharing 7 7 8 + private let terminalStateLogger = SupaLogger("TerminalState") 9 + 8 10 @MainActor 9 11 @Observable 10 12 final class WorktreeTerminalState { ··· 554 556 tabManager.closeAll() 555 557 } 556 558 559 + func makeLayoutSnapshotWorktree() -> TerminalLayoutSnapshotPayload.SnapshotWorktree? { 560 + terminalStateLogger.info( 561 + "[LayoutRestore] makeSnapshot: worktree=\(worktree.id) tabs=\(tabManager.tabs.count)" 562 + ) 563 + guard !tabManager.tabs.isEmpty else { 564 + terminalStateLogger.info("[LayoutRestore] makeSnapshot: no tabs, returning nil") 565 + return nil 566 + } 567 + 568 + var snapshotTabs: [TerminalLayoutSnapshotPayload.SnapshotTab] = [] 569 + snapshotTabs.reserveCapacity(tabManager.tabs.count) 570 + for tab in tabManager.tabs { 571 + guard let tree = trees[tab.id], let root = tree.root else { 572 + terminalStateLogger.warning( 573 + "[LayoutRestore] makeSnapshot: no tree/root for tab \(tab.id.rawValue.uuidString)" 574 + ) 575 + return nil 576 + } 577 + guard let splitRoot = makeLayoutSnapshotNode(from: root) else { 578 + terminalStateLogger.warning( 579 + "[LayoutRestore] makeSnapshot: failed to snapshot split tree for tab \(tab.id.rawValue.uuidString)" 580 + ) 581 + return nil 582 + } 583 + snapshotTabs.append( 584 + TerminalLayoutSnapshotPayload.SnapshotTab( 585 + tabID: tab.id.rawValue.uuidString, 586 + splitRoot: splitRoot 587 + ) 588 + ) 589 + } 590 + 591 + let result = TerminalLayoutSnapshotPayload.SnapshotWorktree( 592 + worktreeID: worktree.id, 593 + selectedTabID: tabManager.selectedTabId?.rawValue.uuidString, 594 + tabs: snapshotTabs 595 + ) 596 + terminalStateLogger.info( 597 + "[LayoutRestore] makeSnapshot: success, \(snapshotTabs.count) tab(s) captured" 598 + ) 599 + return result 600 + } 601 + 602 + func applyLayoutSnapshot(_ snapshot: TerminalLayoutSnapshotPayload.SnapshotWorktree) -> Bool { 603 + terminalStateLogger.info( 604 + "[LayoutRestore] applySnapshot: worktree=\(worktree.id)" 605 + + " snapshotWorktreeID=\(snapshot.worktreeID) tabs=\(snapshot.tabs.count)" 606 + ) 607 + guard snapshot.worktreeID == worktree.id else { 608 + terminalStateLogger.warning("[LayoutRestore] applySnapshot: worktreeID mismatch") 609 + return false 610 + } 611 + 612 + // Validate snapshot structure before creating any surfaces. 613 + var validatedTabs: [(tabID: TerminalTabID, snapshotTab: TerminalLayoutSnapshotPayload.SnapshotTab)] = [] 614 + var seenTabIDs: Set<TerminalTabID> = [] 615 + for snapshotTab in snapshot.tabs { 616 + guard let tabUUID = UUID(uuidString: snapshotTab.tabID) else { 617 + terminalStateLogger.warning("[LayoutRestore] applySnapshot: invalid tab UUID \(snapshotTab.tabID)") 618 + return false 619 + } 620 + let tabID = TerminalTabID(rawValue: tabUUID) 621 + guard seenTabIDs.insert(tabID).inserted else { 622 + terminalStateLogger.warning("[LayoutRestore] applySnapshot: duplicate tab ID \(snapshotTab.tabID)") 623 + return false 624 + } 625 + validatedTabs.append((tabID: tabID, snapshotTab: snapshotTab)) 626 + } 627 + 628 + let selectedTabID: TerminalTabID? 629 + if let selectedTabRaw = snapshot.selectedTabID { 630 + guard let selectedUUID = UUID(uuidString: selectedTabRaw) else { 631 + terminalStateLogger.warning("[LayoutRestore] applySnapshot: invalid selectedTab UUID \(selectedTabRaw)") 632 + return false 633 + } 634 + let candidate = TerminalTabID(rawValue: selectedUUID) 635 + guard seenTabIDs.contains(candidate) else { 636 + terminalStateLogger.warning("[LayoutRestore] applySnapshot: selectedTab not in restored tabs") 637 + return false 638 + } 639 + selectedTabID = candidate 640 + } else { 641 + selectedTabID = validatedTabs.first?.tabID 642 + } 643 + 644 + // Close existing surfaces BEFORE creating new ones so new surfaces 645 + // don't get destroyed by closeAllSurfaces(). 646 + terminalStateLogger.info("[LayoutRestore] applySnapshot: closing existing surfaces before restore") 647 + closeAllSurfaces() 648 + 649 + // Now create new surfaces into the clean state. 650 + var restoredTabs: [TerminalTabItem] = [] 651 + var restoredTrees: [TerminalTabID: SplitTree<GhosttySurfaceView>] = [:] 652 + var restoredFocusedSurfaceIDs: [TerminalTabID: UUID] = [:] 653 + 654 + for (index, entry) in validatedTabs.enumerated() { 655 + terminalStateLogger.info( 656 + "[LayoutRestore] applySnapshot: restoring tab[\(index)] id=\(entry.snapshotTab.tabID)" 657 + ) 658 + guard 659 + let rootNode = restoreSplitNode(from: entry.snapshotTab.splitRoot, tabID: entry.tabID, isRoot: true) 660 + else { 661 + terminalStateLogger.warning("[LayoutRestore] applySnapshot: restoreSplitNode failed for tab[\(index)]") 662 + closeAllSurfaces() 663 + return false 664 + } 665 + let tree = SplitTree<GhosttySurfaceView>.restored(root: rootNode) 666 + restoredTrees[entry.tabID] = tree 667 + restoredFocusedSurfaceIDs[entry.tabID] = rootNode.leftmostLeaf().id 668 + restoredTabs.append( 669 + TerminalTabItem( 670 + id: entry.tabID, 671 + title: "\(worktree.name) \(index + 1)", 672 + icon: "terminal" 673 + ) 674 + ) 675 + } 676 + 677 + trees = restoredTrees 678 + focusedSurfaceIdByTab = restoredFocusedSurfaceIDs 679 + tabIsRunningById = Dictionary(uniqueKeysWithValues: restoredTabs.map { ($0.id, false) }) 680 + tabManager.tabs = restoredTabs 681 + tabManager.selectedTabId = selectedTabID 682 + setRunScriptTabId(nil) 683 + 684 + // Explicitly unfocus all restored surfaces so only the focused one blinks. 685 + for surface in surfaces.values { 686 + surface.focusDidChange(false) 687 + } 688 + if let selectedTabID { 689 + focusSurface(in: selectedTabID) 690 + } else { 691 + lastEmittedFocusSurfaceId = nil 692 + } 693 + emitTaskStatusIfChanged() 694 + terminalStateLogger.info( 695 + "[LayoutRestore] applySnapshot: success, restored \(restoredTabs.count) tab(s)" 696 + + " selectedTab=\(selectedTabID?.rawValue.uuidString ?? "nil")" 697 + ) 698 + return true 699 + } 700 + 557 701 func setNotificationsEnabled(_ enabled: Bool) { 558 702 notificationsEnabled = enabled 559 703 if !enabled { ··· 647 791 tabId: TerminalTabID, 648 792 initialInput: String?, 649 793 inheritingFromSurfaceId: UUID?, 794 + workingDirectoryOverride: URL? = nil, 650 795 context: ghostty_surface_context_e 651 796 ) -> GhosttySurfaceView { 652 797 let inherited = inheritedSurfaceConfig(fromSurfaceId: inheritingFromSurfaceId, context: context) ··· 657 802 ) 658 803 let view = GhosttySurfaceView( 659 804 runtime: runtime, 660 - workingDirectory: inherited.workingDirectory ?? worktree.workingDirectory, 805 + workingDirectory: workingDirectoryOverride ?? inherited.workingDirectory ?? worktree.workingDirectory, 661 806 initialInput: initialInput, 662 807 fontSize: resolvedFontSize, 663 808 context: context ··· 758 903 return defaultFontSize 759 904 } 760 905 906 + static func resolveSnapshotWorkingDirectory( 907 + from snapshotPath: String?, 908 + worktreeRoot: URL, 909 + fileManager: FileManager = .default 910 + ) -> URL? { 911 + guard let snapshotPath, 912 + let normalizedPath = PathPolicy.normalizePath(snapshotPath, relativeTo: worktreeRoot) 913 + else { 914 + return nil 915 + } 916 + 917 + let normalizedURL = URL(fileURLWithPath: normalizedPath).standardizedFileURL 918 + var isDirectory: ObjCBool = false 919 + guard fileManager.fileExists(atPath: normalizedPath, isDirectory: &isDirectory), isDirectory.boolValue else { 920 + return nil 921 + } 922 + guard PathPolicy.contains(normalizedURL, in: worktreeRoot) else { 923 + return nil 924 + } 925 + return normalizedURL 926 + } 927 + 761 928 private struct InheritedSurfaceConfig: Equatable { 762 929 let workingDirectory: URL? 763 930 let fontSize: Float32? ··· 907 1074 908 1075 /// How recently the user must have typed for us to consider the exit user-initiated. 909 1076 static let recentInteractionWindow: Duration = .seconds(3) 1077 + 1078 + private func makeLayoutSnapshotNode( 1079 + from node: SplitTree<GhosttySurfaceView>.Node 1080 + ) -> TerminalLayoutSnapshotPayload.SnapshotSplitNode? { 1081 + switch node { 1082 + case .leaf(let view): 1083 + let cwdPath = inheritedSurfaceConfig( 1084 + fromSurfaceId: view.id, 1085 + context: GHOSTTY_SURFACE_CONTEXT_TAB 1086 + ).workingDirectory?.path(percentEncoded: false) 1087 + return .leaf(surfaceID: view.id.uuidString, cwdPath: cwdPath) 1088 + case .split(let split): 1089 + guard let left = makeLayoutSnapshotNode(from: split.left) else { 1090 + return nil 1091 + } 1092 + guard let right = makeLayoutSnapshotNode(from: split.right) else { 1093 + return nil 1094 + } 1095 + return .split( 1096 + direction: snapshotSplitDirection(from: split.direction), 1097 + ratio: split.ratio, 1098 + children: [left, right] 1099 + ) 1100 + } 1101 + } 1102 + 1103 + private func restoreSplitNode( 1104 + from snapshotNode: TerminalLayoutSnapshotPayload.SnapshotSplitNode, 1105 + tabID: TerminalTabID, 1106 + isRoot: Bool 1107 + ) -> SplitTree<GhosttySurfaceView>.Node? { 1108 + switch snapshotNode.kind { 1109 + case .leaf: 1110 + guard let surfaceID = snapshotNode.surfaceID, 1111 + !surfaceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty 1112 + else { 1113 + return nil 1114 + } 1115 + let context: ghostty_surface_context_e = isRoot ? GHOSTTY_SURFACE_CONTEXT_TAB : GHOSTTY_SURFACE_CONTEXT_SPLIT 1116 + let restoredWorkingDirectory = Self.resolveSnapshotWorkingDirectory( 1117 + from: snapshotNode.cwdPath, 1118 + worktreeRoot: worktree.workingDirectory 1119 + ) 1120 + let view = createSurface( 1121 + tabId: tabID, 1122 + initialInput: nil, 1123 + inheritingFromSurfaceId: nil, 1124 + workingDirectoryOverride: restoredWorkingDirectory, 1125 + context: context 1126 + ) 1127 + return .leaf(view: view) 1128 + case .split: 1129 + guard let direction = snapshotNode.direction else { 1130 + return nil 1131 + } 1132 + guard let ratio = snapshotNode.ratio, ratio > 0, ratio < 1 else { 1133 + return nil 1134 + } 1135 + guard let children = snapshotNode.children, children.count == 2 else { 1136 + return nil 1137 + } 1138 + guard let left = restoreSplitNode(from: children[0], tabID: tabID, isRoot: false) else { 1139 + return nil 1140 + } 1141 + guard let right = restoreSplitNode(from: children[1], tabID: tabID, isRoot: false) else { 1142 + return nil 1143 + } 1144 + return .split( 1145 + .init( 1146 + direction: splitDirection(from: direction), 1147 + ratio: ratio, 1148 + left: left, 1149 + right: right 1150 + ) 1151 + ) 1152 + } 1153 + } 1154 + 1155 + private func snapshotSplitDirection( 1156 + from direction: SplitTree<GhosttySurfaceView>.Direction 1157 + ) -> TerminalLayoutSnapshotSplitDirection { 1158 + switch direction { 1159 + case .horizontal: 1160 + .horizontal 1161 + case .vertical: 1162 + .vertical 1163 + } 1164 + } 1165 + 1166 + private func splitDirection( 1167 + from direction: TerminalLayoutSnapshotSplitDirection 1168 + ) -> SplitTree<GhosttySurfaceView>.Direction { 1169 + switch direction { 1170 + case .horizontal: 1171 + .horizontal 1172 + case .vertical: 1173 + .vertical 1174 + } 1175 + } 910 1176 911 1177 func recordKeyInput(forSurfaceID surfaceId: UUID) { 912 1178 lastKeyInputTimeBySurface[surfaceId] = .now
+48
supacode/Support/PathPolicy.swift
··· 1 + import Foundation 2 + 3 + nonisolated enum PathPolicy { 4 + static func normalizePath( 5 + _ rawPath: String, 6 + relativeTo baseURL: URL? = nil, 7 + resolvingSymlinks: Bool = true 8 + ) -> String? { 9 + let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) 10 + guard !trimmed.isEmpty else { 11 + return nil 12 + } 13 + let expanded = NSString(string: trimmed).expandingTildeInPath 14 + let url: URL 15 + if expanded.hasPrefix("/") { 16 + url = URL(fileURLWithPath: expanded) 17 + } else if let baseURL { 18 + url = baseURL.standardizedFileURL.appending(path: expanded, directoryHint: .inferFromPath) 19 + } else { 20 + url = FileManager.default.homeDirectoryForCurrentUser 21 + .appending(path: expanded, directoryHint: .inferFromPath) 22 + } 23 + return normalizeURL(url, resolvingSymlinks: resolvingSymlinks) 24 + .path(percentEncoded: false) 25 + } 26 + 27 + static func normalizeURL( 28 + _ url: URL, 29 + resolvingSymlinks: Bool = true 30 + ) -> URL { 31 + var normalized = url.standardizedFileURL 32 + if resolvingSymlinks, 33 + FileManager.default.fileExists(atPath: normalized.path(percentEncoded: false)) 34 + { 35 + normalized = normalized.resolvingSymlinksInPath().standardizedFileURL 36 + } 37 + return normalized 38 + } 39 + 40 + static func contains(_ path: URL, in baseDirectory: URL) -> Bool { 41 + let normalizedPath = normalizeURL(path).pathComponents 42 + let normalizedBase = normalizeURL(baseDirectory).pathComponents 43 + guard normalizedPath.count >= normalizedBase.count else { 44 + return false 45 + } 46 + return Array(normalizedPath.prefix(normalizedBase.count)) == normalizedBase 47 + } 48 + }
+66 -22
supacode/Support/SupacodePaths.swift
··· 18 18 baseDirectory.appending(path: "repo", directoryHint: .isDirectory) 19 19 } 20 20 21 + static var appSupportDirectory: URL { 22 + let appSupport = 23 + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first 24 + ?? baseDirectory 25 + return 26 + appSupport 27 + .appending(path: "com.onevcat.prowl", directoryHint: .isDirectory) 28 + .standardizedFileURL 29 + } 30 + 31 + static var cacheDirectory: URL { 32 + appSupportDirectory.appending(path: "cache", directoryHint: .isDirectory) 33 + } 34 + 21 35 static var reposDirectory: URL { 22 36 baseDirectory.appending(path: "repos", directoryHint: .isDirectory) 23 37 } ··· 34 48 guard let rawPath else { 35 49 return nil 36 50 } 37 - let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) 38 - guard !trimmed.isEmpty else { 39 - return nil 40 - } 41 - let expanded = NSString(string: trimmed).expandingTildeInPath 42 - let directoryURL: URL 43 - if expanded.hasPrefix("/") { 44 - directoryURL = URL(filePath: expanded, directoryHint: .isDirectory) 45 - } else if let repositoryRootURL { 46 - directoryURL = repositoryRootURL.standardizedFileURL 47 - .appending(path: expanded, directoryHint: .isDirectory) 48 - } else { 49 - directoryURL = FileManager.default.homeDirectoryForCurrentUser 50 - .appending(path: expanded, directoryHint: .isDirectory) 51 - } 52 - return directoryURL.standardizedFileURL.path(percentEncoded: false) 51 + return PathPolicy.normalizePath( 52 + rawPath, 53 + relativeTo: repositoryRootURL, 54 + resolvingSymlinks: false 55 + ) 53 56 } 54 57 55 58 static func worktreeBaseDirectory( ··· 62 65 repositoryOverridePath, 63 66 repositoryRootURL: rootURL 64 67 ) { 65 - return URL(filePath: repositoryOverridePath, directoryHint: .isDirectory).standardizedFileURL 68 + return PathPolicy.normalizeURL( 69 + URL(filePath: repositoryOverridePath, directoryHint: .isDirectory), 70 + resolvingSymlinks: false 71 + ) 66 72 } 67 73 if let globalDefaultPath = normalizedWorktreeBaseDirectoryPath(globalDefaultPath) { 68 - return URL(filePath: globalDefaultPath, directoryHint: .isDirectory) 69 - .standardizedFileURL 70 - .appending(path: repositoryDirectoryName(for: rootURL), directoryHint: .isDirectory) 71 - .standardizedFileURL 74 + return PathPolicy.normalizeURL( 75 + URL(filePath: globalDefaultPath, directoryHint: .isDirectory), 76 + resolvingSymlinks: false 77 + ) 78 + .appending(path: repositoryDirectoryName(for: rootURL), directoryHint: .isDirectory) 79 + .standardizedFileURL 72 80 } 73 81 return repositoryDirectory(for: rootURL) 74 82 } ··· 94 102 } 95 103 96 104 static var repositorySnapshotURL: URL { 97 - baseDirectory.appending(path: "repository-snapshot.json", directoryHint: .notDirectory) 105 + cacheDirectory.appending(path: "repository-snapshot.json", directoryHint: .notDirectory) 106 + } 107 + 108 + static var terminalLayoutSnapshotURL: URL { 109 + cacheDirectory.appending(path: "terminal-layout-snapshot.json", directoryHint: .notDirectory) 98 110 } 99 111 100 112 static var repositoryEntriesURL: URL { 101 113 baseDirectory.appending(path: "repository-entries.json", directoryHint: .notDirectory) 114 + } 115 + 116 + static func migrateLegacyCacheFilesIfNeeded( 117 + fileManager: FileManager = .default, 118 + legacyDirectory: URL? = nil, 119 + cacheDirectory: URL? = nil 120 + ) throws { 121 + let sourceDirectory = (legacyDirectory ?? baseDirectory).standardizedFileURL 122 + let destinationDirectory = (cacheDirectory ?? self.cacheDirectory).standardizedFileURL 123 + try fileManager.createDirectory(at: destinationDirectory, withIntermediateDirectories: true) 124 + 125 + let fileNames = [ 126 + "repository-snapshot.json", 127 + "terminal-layout-snapshot.json", 128 + ] 129 + 130 + for name in fileNames { 131 + let legacyURL = sourceDirectory.appending(path: name, directoryHint: .notDirectory) 132 + let destinationURL = destinationDirectory.appending(path: name, directoryHint: .notDirectory) 133 + guard !fileManager.fileExists(atPath: destinationURL.path(percentEncoded: false)) else { 134 + continue 135 + } 136 + guard fileManager.fileExists(atPath: legacyURL.path(percentEncoded: false)) else { 137 + continue 138 + } 139 + do { 140 + try fileManager.moveItem(at: legacyURL, to: destinationURL) 141 + } catch { 142 + try fileManager.copyItem(at: legacyURL, to: destinationURL) 143 + try? fileManager.removeItem(at: legacyURL) 144 + } 145 + } 102 146 } 103 147 104 148 static func repositorySettingsURL(for rootURL: URL) -> URL {
+13
supacodeTests/AppFeatureSettingsChangedTests.swift
··· 55 55 #expect(sentTerminalCommands.value.isEmpty) 56 56 #expect(watcherCommands.value.isEmpty) 57 57 } 58 + 59 + @Test(.dependencies) func clearTerminalLayoutSnapshotShowsSuccessToast() async { 60 + let store = TestStore(initialState: AppFeature.State()) { 61 + AppFeature() 62 + } 63 + store.exhaustivity = .off 64 + 65 + await store.send(.settings(.delegate(.terminalLayoutSnapshotCleared(success: true)))) 66 + await store.receive(\.repositories.showToast) { 67 + $0.repositories.statusToast = .success("Saved terminal layout cleared") 68 + } 69 + await store.skipInFlightEffects() 70 + } 58 71 }
+186
supacodeTests/AppFeatureTerminalLayoutRestoreTests.swift
··· 1 + import ComposableArchitecture 2 + import DependenciesTestSupport 3 + import Foundation 4 + import IdentifiedCollections 5 + import SwiftUI 6 + import Testing 7 + 8 + @testable import supacode 9 + 10 + @MainActor 11 + struct AppFeatureTerminalLayoutRestoreTests { 12 + @Test(.dependencies) func repositoriesChangedRestoresLayoutOnceWhenEnabled() async { 13 + let worktree = makeWorktree() 14 + let repository = makeRepository(worktrees: [worktree]) 15 + var repositoriesState = RepositoriesFeature.State(repositories: [repository]) 16 + repositoriesState.snapshotPersistencePhase = .active 17 + var settings = SettingsFeature.State() 18 + settings.restoreTerminalLayoutOnLaunch = true 19 + let sentCommands = LockIsolated<[TerminalClient.Command]>([]) 20 + 21 + let store = TestStore( 22 + initialState: AppFeature.State(repositories: repositoriesState, settings: settings) 23 + ) { 24 + AppFeature() 25 + } withDependencies: { 26 + $0.terminalClient.send = { command in 27 + sentCommands.withValue { $0.append(command) } 28 + } 29 + $0.worktreeInfoWatcher.send = { _ in } 30 + } 31 + store.exhaustivity = .off 32 + 33 + await store.send(.repositories(.delegate(.repositoriesChanged([repository])))) { 34 + $0.launchRestoreMode = .lastFocusedWorktree 35 + $0.repositories.selection = nil 36 + } 37 + await store.finish() 38 + 39 + #expect( 40 + sentCommands.value.contains( 41 + .restoreLayoutSnapshot(worktrees: [worktree]) 42 + ) 43 + ) 44 + } 45 + 46 + @Test(.dependencies) func repositoriesChangedSkipsRestoreWhenDisabled() async { 47 + let worktree = makeWorktree() 48 + let repository = makeRepository(worktrees: [worktree]) 49 + var repositoriesState = RepositoriesFeature.State(repositories: [repository]) 50 + repositoriesState.snapshotPersistencePhase = .active 51 + let sentCommands = LockIsolated<[TerminalClient.Command]>([]) 52 + 53 + let store = TestStore( 54 + initialState: AppFeature.State(repositories: repositoriesState, settings: SettingsFeature.State()) 55 + ) { 56 + AppFeature() 57 + } withDependencies: { 58 + $0.terminalClient.send = { command in 59 + sentCommands.withValue { $0.append(command) } 60 + } 61 + $0.worktreeInfoWatcher.send = { _ in } 62 + } 63 + store.exhaustivity = .off 64 + 65 + await store.send(.repositories(.delegate(.repositoriesChanged([repository])))) 66 + await store.finish() 67 + 68 + #expect( 69 + sentCommands.value.contains { 70 + if case .restoreLayoutSnapshot = $0 { 71 + return true 72 + } 73 + return false 74 + } == false 75 + ) 76 + } 77 + 78 + @Test(.dependencies) func restoreOnlyTriggersOnce() async { 79 + let worktree = makeWorktree() 80 + let repository = makeRepository(worktrees: [worktree]) 81 + var repositoriesState = RepositoriesFeature.State(repositories: [repository]) 82 + repositoriesState.snapshotPersistencePhase = .active 83 + var settings = SettingsFeature.State() 84 + settings.restoreTerminalLayoutOnLaunch = true 85 + let sentCommands = LockIsolated<[TerminalClient.Command]>([]) 86 + 87 + let store = TestStore( 88 + initialState: AppFeature.State(repositories: repositoriesState, settings: settings) 89 + ) { 90 + AppFeature() 91 + } withDependencies: { 92 + $0.terminalClient.send = { command in 93 + sentCommands.withValue { $0.append(command) } 94 + } 95 + $0.worktreeInfoWatcher.send = { _ in } 96 + } 97 + store.exhaustivity = .off 98 + 99 + // First repositoriesChanged triggers restore and flips mode 100 + await store.send(.repositories(.delegate(.repositoriesChanged([repository])))) { 101 + $0.launchRestoreMode = .lastFocusedWorktree 102 + $0.repositories.selection = nil 103 + } 104 + await store.finish() 105 + 106 + sentCommands.withValue { $0.removeAll() } 107 + 108 + // Second repositoriesChanged should NOT trigger restore 109 + await store.send(.repositories(.delegate(.repositoriesChanged([repository])))) 110 + await store.finish() 111 + 112 + #expect( 113 + sentCommands.value.contains { 114 + if case .restoreLayoutSnapshot = $0 { 115 + return true 116 + } 117 + return false 118 + } == false 119 + ) 120 + } 121 + 122 + @Test(.dependencies) func layoutRestoredEventSelectsWorktree() async { 123 + let store = TestStore(initialState: AppFeature.State()) { 124 + AppFeature() 125 + } 126 + store.exhaustivity = .off 127 + 128 + await store.send(.terminalEvent(.layoutRestored(selectedWorktreeID: "/tmp/repo/wt-1"))) 129 + await store.receive(\.repositories.selectWorktree) 130 + } 131 + 132 + @Test(.dependencies) func scenePhaseInactiveSavesLayoutSnapshot() async { 133 + let sentCommands = LockIsolated<[TerminalClient.Command]>([]) 134 + var settings = SettingsFeature.State() 135 + settings.restoreTerminalLayoutOnLaunch = true 136 + let store = TestStore(initialState: AppFeature.State(settings: settings)) { 137 + AppFeature() 138 + } withDependencies: { 139 + $0.terminalClient.send = { command in 140 + sentCommands.withValue { $0.append(command) } 141 + } 142 + } 143 + store.exhaustivity = .off 144 + 145 + await store.send(.scenePhaseChanged(.inactive)) 146 + await store.finish() 147 + 148 + #expect(sentCommands.value == [.saveLayoutSnapshot]) 149 + } 150 + 151 + @Test(.dependencies) func scenePhaseInactiveSkipsSaveWhenRestoreDisabled() async { 152 + let sentCommands = LockIsolated<[TerminalClient.Command]>([]) 153 + let store = TestStore(initialState: AppFeature.State()) { 154 + AppFeature() 155 + } withDependencies: { 156 + $0.terminalClient.send = { command in 157 + sentCommands.withValue { $0.append(command) } 158 + } 159 + } 160 + store.exhaustivity = .off 161 + 162 + await store.send(.scenePhaseChanged(.inactive)) 163 + await store.finish() 164 + 165 + #expect(!sentCommands.value.contains(.saveLayoutSnapshot)) 166 + } 167 + } 168 + 169 + private func makeWorktree() -> Worktree { 170 + Worktree( 171 + id: "/tmp/repo/wt-1", 172 + name: "wt-1", 173 + detail: "", 174 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-1"), 175 + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") 176 + ) 177 + } 178 + 179 + private func makeRepository(worktrees: [Worktree]) -> Repository { 180 + Repository( 181 + id: "/tmp/repo", 182 + rootURL: URL(fileURLWithPath: "/tmp/repo"), 183 + name: "repo", 184 + worktrees: IdentifiedArray(uniqueElements: worktrees) 185 + ) 186 + }
+65 -6
supacodeTests/RepositoriesFeatureTests.swift
··· 27 27 await store.receive(\.repositoriesLoaded) { 28 28 $0.isRefreshingWorktrees = false 29 29 $0.isInitialLoadComplete = true 30 + $0.snapshotPersistencePhase = .active 30 31 } 31 32 } 32 33 ··· 62 63 ) { 63 64 $0.isRefreshingWorktrees = false 64 65 $0.isInitialLoadComplete = true 66 + $0.snapshotPersistencePhase = .active 65 67 } 66 68 } 67 69 ··· 85 87 } 86 88 } 87 89 88 - await store.send(.task) 90 + await store.send(.task) { 91 + $0.snapshotPersistencePhase = .restoring 92 + } 89 93 await store.receive(\.pinnedWorktreeIDsLoaded) 90 94 await store.receive(\.archivedWorktreeIDsLoaded) 91 95 await store.receive(\.repositoryOrderIDsLoaded) ··· 107 111 108 112 await liveRefreshGate.resume() 109 113 110 - await store.receive(\.repositoriesLoaded) 114 + await store.receive(\.repositoriesLoaded) { 115 + $0.snapshotPersistencePhase = .active 116 + } 111 117 await store.finish() 112 118 } 113 119 ··· 125 131 $0.gitClient.worktrees = { _ in [worktree] } 126 132 } 127 133 128 - await store.send(.task) 134 + await store.send(.task) { 135 + $0.snapshotPersistencePhase = .restoring 136 + } 129 137 await store.receive(\.pinnedWorktreeIDsLoaded) 130 138 await store.receive(\.archivedWorktreeIDsLoaded) 131 139 await store.receive(\.repositoryOrderIDsLoaded) ··· 140 148 $0.repositoryRoots = [URL(fileURLWithPath: repoRoot)] 141 149 $0.shouldRestoreLastFocusedWorktree = false 142 150 $0.isInitialLoadComplete = true 151 + $0.snapshotPersistencePhase = .active 143 152 } 144 153 await store.receive(\.delegate.repositoriesChanged) 145 154 await store.finish() ··· 182 191 $0.repositories = [gitRepository, plainRepository] 183 192 $0.repositoryRoots = [repoRoot, plainRoot].map { URL(fileURLWithPath: $0) } 184 193 $0.isInitialLoadComplete = true 194 + $0.snapshotPersistencePhase = .active 185 195 } 186 196 await store.receive(\.delegate.repositoriesChanged) 187 197 await store.finish() ··· 223 233 $0.repositories = [upgradedRepository] 224 234 $0.repositoryRoots = [URL(fileURLWithPath: root)] 225 235 $0.isInitialLoadComplete = true 236 + $0.snapshotPersistencePhase = .active 226 237 } 227 238 await store.receive(\.delegate.repositoriesChanged) 228 239 await store.finish() 229 240 230 241 let expectedSavedEntries = [ 231 - [PersistedRepositoryEntry(path: root, kind: .git)], 242 + [PersistedRepositoryEntry(path: root, kind: .git)] 232 243 ] 233 244 #expect(savedEntries.value == expectedSavedEntries) 234 245 } ··· 269 280 $0.repositories = [plainRepository] 270 281 $0.repositoryRoots = [URL(fileURLWithPath: root)] 271 282 $0.isInitialLoadComplete = true 283 + $0.snapshotPersistencePhase = .active 272 284 } 273 285 await store.receive(\.delegate.repositoriesChanged) 274 286 await store.finish() ··· 312 324 $0.repositories = [downgradedRepository] 313 325 $0.repositoryRoots = [URL(fileURLWithPath: root)] 314 326 $0.isInitialLoadComplete = true 327 + $0.snapshotPersistencePhase = .active 315 328 } 316 329 await store.receive(\.delegate.repositoriesChanged) 317 330 await store.finish() 318 331 319 332 let expectedSavedEntries = [ 320 - [PersistedRepositoryEntry(path: root, kind: .plain)], 333 + [PersistedRepositoryEntry(path: root, kind: .plain)] 321 334 ] 322 335 #expect(savedEntries.value == expectedSavedEntries) 323 336 } ··· 352 365 $0.repositories = [repository] 353 366 $0.repositoryRoots = [URL(fileURLWithPath: root)] 354 367 $0.isInitialLoadComplete = true 368 + $0.snapshotPersistencePhase = .active 355 369 } 356 370 await store.receive(\.delegate.repositoriesChanged) 357 371 await store.finish() ··· 412 426 $0.repositories = [gitRepository, plainRepository] 413 427 $0.repositoryRoots = [repoRoot, plainRoot].map { URL(fileURLWithPath: $0) } 414 428 $0.isInitialLoadComplete = true 429 + $0.snapshotPersistencePhase = .active 415 430 } 416 431 await store.receive(\.delegate.repositoriesChanged) 417 432 await store.finish() ··· 479 494 $0.repositoryRoots = [repoRoot].map { URL(fileURLWithPath: $0) } 480 495 $0.isInitialLoadComplete = true 481 496 $0.alert = expectedAlert 497 + $0.snapshotPersistencePhase = .active 482 498 } 483 499 await store.receive(\.delegate.repositoriesChanged) 484 500 await store.finish() 485 501 486 502 let expectedSavedEntries = [ 487 - [PersistedRepositoryEntry(path: repoRoot, kind: .git)], 503 + [PersistedRepositoryEntry(path: repoRoot, kind: .git)] 488 504 ] 489 505 #expect(savedEntries.value == expectedSavedEntries) 490 506 } 491 507 508 + @Test func repositoriesLoadedSkipsRepositorySnapshotPersistenceWhileRestoring() async { 509 + let repoRoot = "/tmp/repo" 510 + let worktree = makeWorktree(id: "\(repoRoot)/main", name: "main", repoRoot: repoRoot) 511 + let repository = makeRepository(id: repoRoot, worktrees: [worktree]) 512 + let savedSnapshots = LockIsolated<[[Repository]]>([]) 513 + var state = RepositoriesFeature.State() 514 + state.snapshotPersistencePhase = .restoring 515 + 516 + let store = TestStore(initialState: state) { 517 + RepositoriesFeature() 518 + } withDependencies: { 519 + $0.repositoryPersistence.saveRepositorySnapshot = { repositories in 520 + savedSnapshots.withValue { $0.append(repositories) } 521 + } 522 + } 523 + 524 + await store.send( 525 + .repositoriesLoaded( 526 + [repository], 527 + failures: [], 528 + roots: [URL(fileURLWithPath: repoRoot)], 529 + animated: false 530 + ) 531 + ) { 532 + $0.repositories = [repository] 533 + $0.repositoryRoots = [URL(fileURLWithPath: repoRoot)] 534 + $0.isInitialLoadComplete = true 535 + $0.snapshotPersistencePhase = .active 536 + } 537 + await store.receive(\.delegate.repositoriesChanged) 538 + await store.finish() 539 + 540 + #expect(savedSnapshots.value.isEmpty) 541 + } 542 + 492 543 @Test func repositoriesLoadedPersistsRepositorySnapshotOnSuccess() async { 493 544 let repoRoot = "/tmp/repo" 494 545 let worktree = makeWorktree(id: "\(repoRoot)/main", name: "main", repoRoot: repoRoot) ··· 514 565 $0.repositories = [repository] 515 566 $0.repositoryRoots = [URL(fileURLWithPath: repoRoot)] 516 567 $0.isInitialLoadComplete = true 568 + $0.snapshotPersistencePhase = .active 517 569 } 518 570 await store.receive(\.delegate.repositoriesChanged) 519 571 await store.finish() ··· 2185 2237 ) { 2186 2238 $0.repositories = [updatedRepository] 2187 2239 $0.isInitialLoadComplete = true 2240 + $0.snapshotPersistencePhase = .active 2188 2241 } 2189 2242 await store.receive(\.delegate.repositoriesChanged) 2190 2243 await store.finish() ··· 2213 2266 $0.repositories = [updatedRepository] 2214 2267 $0.selection = nil 2215 2268 $0.isInitialLoadComplete = true 2269 + $0.snapshotPersistencePhase = .active 2216 2270 } 2217 2271 await store.receive(\.delegate.repositoriesChanged) 2218 2272 await store.receive(\.delegate.selectedWorktreeChanged) ··· 2266 2320 await store.receive(\.reloadRepositories) 2267 2321 await store.receive(\.repositoriesLoaded) { 2268 2322 $0.isInitialLoadComplete = true 2323 + $0.snapshotPersistencePhase = .active 2269 2324 } 2270 2325 } 2271 2326 ··· 2301 2356 await store.receive(\.reloadRepositories) 2302 2357 await store.receive(\.repositoriesLoaded) { 2303 2358 $0.isInitialLoadComplete = true 2359 + $0.snapshotPersistencePhase = .active 2304 2360 } 2305 2361 } 2306 2362 ··· 2348 2404 await store.receive(\.delegate.worktreeCreated) 2349 2405 await store.receive(\.repositoriesLoaded) { 2350 2406 $0.isInitialLoadComplete = true 2407 + $0.snapshotPersistencePhase = .active 2351 2408 } 2352 2409 } 2353 2410 ··· 3149 3206 $0.repositories = [repoA, repoB] 3150 3207 $0.repositoryRoots = [repoRootA, repoRootB].map { URL(fileURLWithPath: $0) } 3151 3208 $0.isInitialLoadComplete = true 3209 + $0.snapshotPersistencePhase = .active 3152 3210 } 3153 3211 await store.receive(\.delegate.repositoriesChanged) 3154 3212 await store.finish() ··· 3199 3257 $0.selection = .worktree(worktreeB.id) 3200 3258 $0.shouldRestoreLastFocusedWorktree = false 3201 3259 $0.isInitialLoadComplete = true 3260 + $0.snapshotPersistencePhase = .active 3202 3261 } 3203 3262 await store.receive(\.delegate.repositoriesChanged) 3204 3263 await store.receive(\.delegate.selectedWorktreeChanged)
+42
supacodeTests/RepositoryPathsTests.swift
··· 110 110 111 111 #expect(path == expectedPath) 112 112 } 113 + 114 + @Test func repositorySnapshotURLUsesAppSupportCacheDirectory() { 115 + let path = SupacodePaths.repositorySnapshotURL.path(percentEncoded: false) 116 + 117 + #expect(path.contains("/Library/Application Support/com.onevcat.prowl/cache/")) 118 + } 119 + 120 + @Test func migrateLegacyCacheMovesSnapshotFilesToCacheDirectory() throws { 121 + let tempRoot = URL(fileURLWithPath: NSTemporaryDirectory()) 122 + .appending(path: UUID().uuidString, directoryHint: .isDirectory) 123 + let legacyDirectory = tempRoot.appending(path: "legacy", directoryHint: .isDirectory) 124 + let cacheDirectory = tempRoot.appending(path: "cache", directoryHint: .isDirectory) 125 + let repositorySnapshot = legacyDirectory.appending(path: "repository-snapshot.json", directoryHint: .notDirectory) 126 + let terminalSnapshot = legacyDirectory.appending( 127 + path: "terminal-layout-snapshot.json", 128 + directoryHint: .notDirectory 129 + ) 130 + 131 + try FileManager.default.createDirectory(at: legacyDirectory, withIntermediateDirectories: true) 132 + try Data("repo".utf8).write(to: repositorySnapshot) 133 + try Data("terminal".utf8).write(to: terminalSnapshot) 134 + 135 + try SupacodePaths.migrateLegacyCacheFilesIfNeeded( 136 + legacyDirectory: legacyDirectory, 137 + cacheDirectory: cacheDirectory 138 + ) 139 + 140 + let migratedRepositorySnapshotPath = 141 + cacheDirectory 142 + .appending(path: "repository-snapshot.json") 143 + .path(percentEncoded: false) 144 + let migratedTerminalSnapshotPath = 145 + cacheDirectory 146 + .appending(path: "terminal-layout-snapshot.json") 147 + .path(percentEncoded: false) 148 + #expect(FileManager.default.fileExists(atPath: migratedRepositorySnapshotPath)) 149 + #expect(FileManager.default.fileExists(atPath: migratedTerminalSnapshotPath)) 150 + #expect(!FileManager.default.fileExists(atPath: repositorySnapshot.path(percentEncoded: false))) 151 + #expect(!FileManager.default.fileExists(atPath: terminalSnapshot.path(percentEncoded: false))) 152 + 153 + try? FileManager.default.removeItem(at: tempRoot) 154 + } 113 155 }
+41 -1
supacodeTests/RepositoryPersistenceClientTests.swift
··· 89 89 repositoryRoots: ["/tmp/repo-a", "/tmp/repo-b/../repo-b"] 90 90 ) 91 91 92 - try withDependencies { 92 + withDependencies { 93 93 $0.settingsFileStorage = storage.storage 94 94 } operation: { 95 95 @Shared(.settingsFile) var settings: SettingsFile ··· 156 156 let restored = payload.restoreRepositories { path in 157 157 path == repoRoot 158 158 } 159 + 160 + #expect(restored == nil) 161 + } 162 + 163 + @Test func repositorySnapshotPayloadRejectsTooManyRepositories() { 164 + let repositories = (0..<RepositorySnapshotCachePayload.maxRepositories + 1).map { index in 165 + Repository( 166 + id: "/tmp/repo-\(index)", 167 + rootURL: URL(fileURLWithPath: "/tmp/repo-\(index)"), 168 + name: "repo-\(index)", 169 + worktrees: IdentifiedArray() 170 + ) 171 + } 172 + 173 + let payload = RepositorySnapshotCachePayload(repositories: repositories) 174 + let restored = payload.restoreRepositories { _ in true } 175 + 176 + #expect(restored == nil) 177 + } 178 + 179 + @Test func repositorySnapshotPayloadRejectsTooManyWorktreesPerRepository() { 180 + let repoRoot = "/tmp/repo" 181 + let worktrees = (0..<RepositorySnapshotCachePayload.maxWorktreesPerRepository + 1).map { index in 182 + Worktree( 183 + id: "\(repoRoot)/wt-\(index)", 184 + name: "wt-\(index)", 185 + detail: ".", 186 + workingDirectory: URL(fileURLWithPath: "\(repoRoot)/wt-\(index)"), 187 + repositoryRootURL: URL(fileURLWithPath: repoRoot) 188 + ) 189 + } 190 + let repository = Repository( 191 + id: repoRoot, 192 + rootURL: URL(fileURLWithPath: repoRoot), 193 + name: "repo", 194 + worktrees: IdentifiedArray(uniqueElements: worktrees) 195 + ) 196 + 197 + let payload = RepositorySnapshotCachePayload(repositories: [repository]) 198 + let restored = payload.restoreRepositories { _ in true } 159 199 160 200 #expect(restored == nil) 161 201 }
+13 -8
supacodeTests/SettingsFeatureTests.swift
··· 206 206 @Test(.dependencies) func settingsLoadedNormalizesDefaultWorktreeBaseDirectoryPath() async { 207 207 var loaded = GlobalSettings.default 208 208 loaded.defaultWorktreeBaseDirectoryPath = " ~/worktrees " 209 - let expectedPath = FileManager.default.homeDirectoryForCurrentUser 210 - .appending(path: "worktrees", directoryHint: .isDirectory) 211 - .standardizedFileURL 212 - .path(percentEncoded: false) 209 + let expectedPath = SupacodePaths.normalizedWorktreeBaseDirectoryPath(" ~/worktrees ")! 213 210 let storage = SettingsTestStorage() 214 211 let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json") 215 212 let store = TestStore(initialState: SettingsFeature.State()) { ··· 228 225 229 226 @Test(.dependencies) func changingDefaultWorktreeBaseDirectoryUpdatesRepositorySettingsState() async { 230 227 let rootURL = URL(fileURLWithPath: "/tmp/repo") 231 - let expectedPath = FileManager.default.homeDirectoryForCurrentUser 232 - .appending(path: "worktrees", directoryHint: .isDirectory) 233 - .standardizedFileURL 234 - .path(percentEncoded: false) 228 + let expectedPath = SupacodePaths.normalizedWorktreeBaseDirectoryPath(" ~/worktrees ")! 235 229 @Shared(.settingsFile) var settingsFile 236 230 $settingsFile.withLock { $0.global = .default } 237 231 var state = SettingsFeature.State() ··· 301 295 302 296 #expect(settingsFile.global.terminalFontSize == 18) 303 297 #expect(capturedEvents.value.isEmpty) 298 + } 299 + 300 + @Test(.dependencies) func clearTerminalLayoutSnapshotSendsDelegate() async { 301 + let store = TestStore(initialState: SettingsFeature.State()) { 302 + SettingsFeature() 303 + } withDependencies: { 304 + $0.terminalLayoutPersistence.clearSnapshot = { true } 305 + } 306 + 307 + await store.send(.clearTerminalLayoutSnapshotButtonTapped) 308 + await store.receive(\.delegate.terminalLayoutSnapshotCleared) 304 309 } 305 310 }
+1
supacodeTests/SettingsFilePersistenceTests.swift
··· 111 111 #expect(settings.global.automaticallyArchiveMergedWorktrees == false) 112 112 #expect(settings.global.promptForWorktreeCreation == true) 113 113 #expect(settings.global.defaultWorktreeBaseDirectoryPath == nil) 114 + #expect(settings.global.restoreTerminalLayoutOnLaunch == false) 114 115 #expect(settings.global.defaultEditorID == OpenWorktreeAction.automaticSettingsID) 115 116 #expect(settings.repositoryRoots.isEmpty) 116 117 #expect(settings.pinnedWorktreeIDs.isEmpty)
+78
supacodeTests/TerminalLayoutPersistenceClientTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import supacode 5 + 6 + struct TerminalLayoutPersistenceClientTests { 7 + @Test func saveAndLoadSnapshotRoundTrips() throws { 8 + let fileManager = FileManager.default 9 + let cacheDirectory = try makeTemporaryDirectory(fileManager: fileManager) 10 + defer { try? fileManager.removeItem(at: cacheDirectory) } 11 + let snapshotURL = cacheDirectory.appending(path: "terminal-layout-snapshot.json", directoryHint: .notDirectory) 12 + let payload = makePayload() 13 + 14 + let saved = saveTerminalLayoutSnapshot( 15 + payload, 16 + at: snapshotURL, 17 + cacheDirectory: cacheDirectory, 18 + fileManager: fileManager 19 + ) 20 + #expect(saved) 21 + 22 + let loaded = loadTerminalLayoutSnapshot(at: snapshotURL, fileManager: fileManager) 23 + #expect(loaded == payload) 24 + } 25 + 26 + @Test func loadSnapshotDeletesInvalidPayload() throws { 27 + let fileManager = FileManager.default 28 + let cacheDirectory = try makeTemporaryDirectory(fileManager: fileManager) 29 + defer { try? fileManager.removeItem(at: cacheDirectory) } 30 + let snapshotURL = cacheDirectory.appending(path: "terminal-layout-snapshot.json", directoryHint: .notDirectory) 31 + try Data("{\"version\":1}".utf8).write(to: snapshotURL, options: .atomic) 32 + 33 + let loaded = loadTerminalLayoutSnapshot(at: snapshotURL, fileManager: fileManager) 34 + 35 + #expect(loaded == nil) 36 + #expect(fileManager.fileExists(atPath: snapshotURL.path(percentEncoded: false)) == false) 37 + } 38 + 39 + @Test func loadSnapshotDeletesOversizedPayload() throws { 40 + let fileManager = FileManager.default 41 + let cacheDirectory = try makeTemporaryDirectory(fileManager: fileManager) 42 + defer { try? fileManager.removeItem(at: cacheDirectory) } 43 + let snapshotURL = cacheDirectory.appending(path: "terminal-layout-snapshot.json", directoryHint: .notDirectory) 44 + let oversized = Data(repeating: 0, count: TerminalLayoutSnapshotPayload.maxSnapshotFileBytes + 1) 45 + try oversized.write(to: snapshotURL, options: .atomic) 46 + 47 + let loaded = loadTerminalLayoutSnapshot(at: snapshotURL, fileManager: fileManager) 48 + 49 + #expect(loaded == nil) 50 + #expect(fileManager.fileExists(atPath: snapshotURL.path(percentEncoded: false)) == false) 51 + } 52 + } 53 + 54 + private func makeTemporaryDirectory(fileManager: FileManager) throws -> URL { 55 + let url = fileManager.temporaryDirectory.appending( 56 + path: "terminal-layout-tests-\(UUID().uuidString)", 57 + directoryHint: .isDirectory 58 + ) 59 + try fileManager.createDirectory(at: url, withIntermediateDirectories: true) 60 + return url 61 + } 62 + 63 + private func makePayload() -> TerminalLayoutSnapshotPayload { 64 + TerminalLayoutSnapshotPayload( 65 + worktrees: [ 66 + TerminalLayoutSnapshotPayload.SnapshotWorktree( 67 + worktreeID: "/tmp/repo/wt-1", 68 + selectedTabID: "F96839F5-1371-4841-9E41-49124D918A67", 69 + tabs: [ 70 + TerminalLayoutSnapshotPayload.SnapshotTab( 71 + tabID: "F96839F5-1371-4841-9E41-49124D918A67", 72 + splitRoot: .leaf(surfaceID: "9B2F6D8C-44A4-42C5-8F9E-962108301901") 73 + ), 74 + ] 75 + ), 76 + ] 77 + ) 78 + }
+291
supacodeTests/TerminalLayoutSnapshotPayloadTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import supacode 5 + 6 + struct TerminalLayoutSnapshotPayloadTests { 7 + @Test func decodeValidatedRoundTripsValidPayload() throws { 8 + let payload = makePayload() 9 + let data = try JSONEncoder().encode(payload) 10 + 11 + let decoded = TerminalLayoutSnapshotPayload.decodeValidated(from: data) 12 + #expect(decoded == payload) 13 + } 14 + 15 + @Test func decodeValidatedRoundTripsLeafCwdPath() throws { 16 + let payload = makePayload(splitRoot: .leaf(surfaceID: "surface-1", cwdPath: "/tmp/repo/wt-1/src")) 17 + let data = try JSONEncoder().encode(payload) 18 + 19 + let decoded = TerminalLayoutSnapshotPayload.decodeValidated(from: data) 20 + #expect(decoded == payload) 21 + } 22 + 23 + @Test func decodeValidatedRejectsOversizedData() { 24 + let data = Data( 25 + repeating: 0, 26 + count: TerminalLayoutSnapshotPayload.maxSnapshotFileBytes + 1 27 + ) 28 + 29 + #expect(TerminalLayoutSnapshotPayload.decodeValidated(from: data) == nil) 30 + } 31 + 32 + @Test func decodeValidatedRejectsSchemaVersionMismatch() throws { 33 + let payload = makePayload(version: TerminalLayoutSnapshotPayload.currentVersion + 1) 34 + let data = try JSONEncoder().encode(payload) 35 + 36 + #expect(TerminalLayoutSnapshotPayload.decodeValidated(from: data) == nil) 37 + } 38 + 39 + @Test func decodeValidatedRejectsTooManyWorktrees() throws { 40 + let worktrees = (0...TerminalLayoutSnapshotPayload.maxWorktrees).map { index in 41 + makeWorktree(worktreeID: "wt-\(index)") 42 + } 43 + let payload = TerminalLayoutSnapshotPayload(worktrees: worktrees) 44 + let data = try JSONEncoder().encode(payload) 45 + 46 + #expect(TerminalLayoutSnapshotPayload.decodeValidated(from: data) == nil) 47 + } 48 + 49 + @Test func decodeValidatedRejectsTooManyTabsInWorktree() throws { 50 + let tabs = (0...TerminalLayoutSnapshotPayload.maxTabsPerWorktree).map { index in 51 + makeTab(tabID: "tab-\(index)") 52 + } 53 + let payload = TerminalLayoutSnapshotPayload( 54 + worktrees: [ 55 + TerminalLayoutSnapshotPayload.SnapshotWorktree( 56 + worktreeID: "wt-1", 57 + selectedTabID: "tab-0", 58 + tabs: tabs 59 + ), 60 + ] 61 + ) 62 + let data = try JSONEncoder().encode(payload) 63 + 64 + #expect(TerminalLayoutSnapshotPayload.decodeValidated(from: data) == nil) 65 + } 66 + 67 + @Test func decodeValidatedRejectsTooManySplitNodesInTab() throws { 68 + var leafIndex = 0 69 + let root = makeBalancedSplitTree(depth: 10, leafIndex: &leafIndex) 70 + let payload = makePayload( 71 + tabID: "tab-large", 72 + splitRoot: root 73 + ) 74 + let data = try JSONEncoder().encode(payload) 75 + 76 + #expect(TerminalLayoutSnapshotPayload.decodeValidated(from: data) == nil) 77 + } 78 + 79 + @Test func decodeValidatedRejectsSplitTreeDepthOverflow() throws { 80 + var leafIndex = 0 81 + let root = makeDeepSplitTree( 82 + splitCount: TerminalLayoutSnapshotPayload.maxSplitDepth, 83 + leafIndex: &leafIndex 84 + ) 85 + let payload = makePayload( 86 + tabID: "tab-deep", 87 + splitRoot: root 88 + ) 89 + let data = try JSONEncoder().encode(payload) 90 + 91 + #expect(TerminalLayoutSnapshotPayload.decodeValidated(from: data) == nil) 92 + } 93 + 94 + @Test func decodeValidatedRejectsIllegalSplitNodeStructure() throws { 95 + let invalidRoot = TerminalLayoutSnapshotPayload.SnapshotSplitNode( 96 + kind: .split, 97 + surfaceID: nil, 98 + cwdPath: nil, 99 + direction: .horizontal, 100 + ratio: 0.5, 101 + children: [ 102 + .leaf(surfaceID: "leaf-1") 103 + ] 104 + ) 105 + let payload = makePayload( 106 + tabID: "tab-invalid", 107 + splitRoot: invalidRoot 108 + ) 109 + let data = try JSONEncoder().encode(payload) 110 + 111 + #expect(TerminalLayoutSnapshotPayload.decodeValidated(from: data) == nil) 112 + } 113 + 114 + @Test func decodeValidatedRejectsSelectedTabMissingFromTabs() throws { 115 + let payload = TerminalLayoutSnapshotPayload( 116 + worktrees: [ 117 + TerminalLayoutSnapshotPayload.SnapshotWorktree( 118 + worktreeID: "wt-1", 119 + selectedTabID: "tab-missing", 120 + tabs: [ 121 + makeTab(tabID: "tab-1") 122 + ] 123 + ), 124 + ] 125 + ) 126 + let data = try JSONEncoder().encode(payload) 127 + 128 + #expect(TerminalLayoutSnapshotPayload.decodeValidated(from: data) == nil) 129 + } 130 + 131 + @Test func decodeValidatedRejectsLeafWithEmptyCwdPath() throws { 132 + let payload = makePayload(splitRoot: .leaf(surfaceID: "surface-1", cwdPath: " ")) 133 + let data = try JSONEncoder().encode(payload) 134 + 135 + #expect(TerminalLayoutSnapshotPayload.decodeValidated(from: data) == nil) 136 + } 137 + 138 + @Test func decodeValidatedRejectsSplitNodeWithCwdPath() throws { 139 + let invalidRoot = TerminalLayoutSnapshotPayload.SnapshotSplitNode( 140 + kind: .split, 141 + surfaceID: nil, 142 + cwdPath: "/tmp/repo/wt-1", 143 + direction: .horizontal, 144 + ratio: 0.5, 145 + children: [ 146 + .leaf(surfaceID: "surface-1"), 147 + .leaf(surfaceID: "surface-2"), 148 + ] 149 + ) 150 + let payload = makePayload(splitRoot: invalidRoot) 151 + let data = try JSONEncoder().encode(payload) 152 + 153 + #expect(TerminalLayoutSnapshotPayload.decodeValidated(from: data) == nil) 154 + } 155 + 156 + @Test func decodeValidatedRejectsTypeMismatchInFields() { 157 + let invalidJSON = #""" 158 + { 159 + "version": 1, 160 + "worktrees": [ 161 + { 162 + "worktreeID": "wt-1", 163 + "selectedTabID": "tab-1", 164 + "tabs": [ 165 + { 166 + "tabID": "tab-1", 167 + "splitRoot": { 168 + "kind": "split", 169 + "direction": "horizontal", 170 + "ratio": "bad", 171 + "children": [] 172 + } 173 + } 174 + ] 175 + } 176 + ] 177 + } 178 + """# 179 + let data = Data(invalidJSON.utf8) 180 + 181 + #expect(TerminalLayoutSnapshotPayload.decodeValidated(from: data) == nil) 182 + } 183 + 184 + @Test func decodeValidatedRoundTripsSelectedWorktreeID() throws { 185 + let payload = TerminalLayoutSnapshotPayload( 186 + selectedWorktreeID: "wt-1", 187 + worktrees: [makeWorktree(worktreeID: "wt-1")] 188 + ) 189 + let data = try JSONEncoder().encode(payload) 190 + 191 + let decoded = TerminalLayoutSnapshotPayload.decodeValidated(from: data) 192 + #expect(decoded == payload) 193 + #expect(decoded?.selectedWorktreeID == "wt-1") 194 + } 195 + 196 + @Test func decodeValidatedAcceptsNilSelectedWorktreeID() throws { 197 + let payload = TerminalLayoutSnapshotPayload( 198 + selectedWorktreeID: nil, 199 + worktrees: [makeWorktree(worktreeID: "wt-1")] 200 + ) 201 + let data = try JSONEncoder().encode(payload) 202 + 203 + let decoded = TerminalLayoutSnapshotPayload.decodeValidated(from: data) 204 + #expect(decoded == payload) 205 + #expect(decoded?.selectedWorktreeID == nil) 206 + } 207 + 208 + @Test func decodeValidatedRejectsSelectedWorktreeIDNotInWorktrees() throws { 209 + let payload = TerminalLayoutSnapshotPayload( 210 + version: TerminalLayoutSnapshotPayload.currentVersion, 211 + selectedWorktreeID: "wt-missing", 212 + worktrees: [makeWorktree(worktreeID: "wt-1")] 213 + ) 214 + let data = try JSONEncoder().encode(payload) 215 + 216 + #expect(TerminalLayoutSnapshotPayload.decodeValidated(from: data) == nil) 217 + } 218 + } 219 + 220 + private func makePayload( 221 + version: Int = TerminalLayoutSnapshotPayload.currentVersion, 222 + worktreeID: String = "wt-1", 223 + tabID: String = "tab-1", 224 + splitRoot: TerminalLayoutSnapshotPayload.SnapshotSplitNode = .leaf(surfaceID: "surface-1") 225 + ) -> TerminalLayoutSnapshotPayload { 226 + TerminalLayoutSnapshotPayload( 227 + version: version, 228 + worktrees: [ 229 + TerminalLayoutSnapshotPayload.SnapshotWorktree( 230 + worktreeID: worktreeID, 231 + selectedTabID: tabID, 232 + tabs: [ 233 + makeTab(tabID: tabID, splitRoot: splitRoot) 234 + ] 235 + ), 236 + ] 237 + ) 238 + } 239 + 240 + private func makeWorktree( 241 + worktreeID: String 242 + ) -> TerminalLayoutSnapshotPayload.SnapshotWorktree { 243 + TerminalLayoutSnapshotPayload.SnapshotWorktree( 244 + worktreeID: worktreeID, 245 + selectedTabID: "tab-1", 246 + tabs: [ 247 + makeTab(tabID: "tab-1") 248 + ] 249 + ) 250 + } 251 + 252 + private func makeTab( 253 + tabID: String, 254 + splitRoot: TerminalLayoutSnapshotPayload.SnapshotSplitNode = .leaf(surfaceID: "surface-1") 255 + ) -> TerminalLayoutSnapshotPayload.SnapshotTab { 256 + TerminalLayoutSnapshotPayload.SnapshotTab( 257 + tabID: tabID, 258 + splitRoot: splitRoot 259 + ) 260 + } 261 + 262 + private func makeBalancedSplitTree( 263 + depth: Int, 264 + leafIndex: inout Int 265 + ) -> TerminalLayoutSnapshotPayload.SnapshotSplitNode { 266 + guard depth > 0 else { 267 + let surfaceID = "surface-\(leafIndex)" 268 + leafIndex += 1 269 + return .leaf(surfaceID: surfaceID) 270 + } 271 + let left = makeBalancedSplitTree(depth: depth - 1, leafIndex: &leafIndex) 272 + let right = makeBalancedSplitTree(depth: depth - 1, leafIndex: &leafIndex) 273 + return .split(direction: .horizontal, ratio: 0.5, children: [left, right]) 274 + } 275 + 276 + private func makeDeepSplitTree( 277 + splitCount: Int, 278 + leafIndex: inout Int 279 + ) -> TerminalLayoutSnapshotPayload.SnapshotSplitNode { 280 + guard splitCount > 0 else { 281 + let surfaceID = "surface-\(leafIndex)" 282 + leafIndex += 1 283 + return .leaf(surfaceID: surfaceID) 284 + } 285 + 286 + let deepBranch = makeDeepSplitTree(splitCount: splitCount - 1, leafIndex: &leafIndex) 287 + let siblingSurfaceID = "surface-\(leafIndex)" 288 + leafIndex += 1 289 + let siblingLeaf = TerminalLayoutSnapshotPayload.SnapshotSplitNode.leaf(surfaceID: siblingSurfaceID) 290 + return .split(direction: .vertical, ratio: 0.5, children: [deepBranch, siblingLeaf]) 291 + }
+60
supacodeTests/WorktreeTerminalManagerTests.swift
··· 1 + import ConcurrencyExtras 2 + import DependenciesTestSupport 1 3 import Foundation 2 4 import Testing 3 5 ··· 319 321 #expect(manager.hasUnseenNotifications(for: worktree.id) == false) 320 322 } 321 323 324 + @Test func restoreLayoutSnapshotFailClosedClearsSnapshotWhenWorktreeMissing() async { 325 + let clearCount = LockIsolated(0) 326 + let snapshot = TerminalLayoutSnapshotPayload( 327 + worktrees: [ 328 + TerminalLayoutSnapshotPayload.SnapshotWorktree( 329 + worktreeID: "/tmp/repo/wt-1", 330 + selectedTabID: "F96839F5-1371-4841-9E41-49124D918A67", 331 + tabs: [ 332 + TerminalLayoutSnapshotPayload.SnapshotTab( 333 + tabID: "F96839F5-1371-4841-9E41-49124D918A67", 334 + splitRoot: .leaf(surfaceID: "9B2F6D8C-44A4-42C5-8F9E-962108301901") 335 + ), 336 + ] 337 + ), 338 + ] 339 + ) 340 + let manager = WorktreeTerminalManager( 341 + runtime: GhosttyRuntime(), 342 + layoutPersistence: TerminalLayoutPersistenceClient( 343 + loadSnapshot: { snapshot }, 344 + saveSnapshot: { _ in true }, 345 + clearSnapshot: { 346 + clearCount.withValue { $0 += 1 } 347 + return true 348 + } 349 + ) 350 + ) 351 + 352 + await manager.restoreLayoutSnapshot(from: []) 353 + 354 + #expect(clearCount.value == 1) 355 + } 356 + 357 + @Test func persistLayoutSnapshotWithoutTabsClearsSnapshot() async { 358 + let clearCount = LockIsolated(0) 359 + let saveCount = LockIsolated(0) 360 + let manager = WorktreeTerminalManager( 361 + runtime: GhosttyRuntime(), 362 + layoutPersistence: TerminalLayoutPersistenceClient( 363 + loadSnapshot: { nil }, 364 + saveSnapshot: { _ in 365 + saveCount.withValue { $0 += 1 } 366 + return true 367 + }, 368 + clearSnapshot: { 369 + clearCount.withValue { $0 += 1 } 370 + return true 371 + } 372 + ) 373 + ) 374 + 375 + await manager.persistLayoutSnapshot() 376 + 377 + #expect(saveCount.value == 0) 378 + #expect(clearCount.value == 1) 379 + } 380 + 322 381 private func makeWorktree() -> Worktree { 323 382 Worktree( 324 383 id: "/tmp/repo/wt-1", ··· 350 409 isRead: isRead 351 410 ) 352 411 } 412 + 353 413 }
+77
supacodeTests/WorktreeTerminalStateSnapshotRestoreTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import supacode 5 + 6 + struct SnapshotRestoreTests { 7 + @Test func resolveSnapshotWorkingDirectoryAcceptsAbsolutePathWithinWorktree() throws { 8 + let fileManager = FileManager.default 9 + let worktreeRoot = try makeTemporaryDirectory(fileManager: fileManager) 10 + defer { try? fileManager.removeItem(at: worktreeRoot) } 11 + 12 + let nested = worktreeRoot.appending(path: "src", directoryHint: .isDirectory) 13 + try fileManager.createDirectory(at: nested, withIntermediateDirectories: true) 14 + 15 + let resolved = WorktreeTerminalState.resolveSnapshotWorkingDirectory( 16 + from: nested.path(percentEncoded: false), 17 + worktreeRoot: worktreeRoot, 18 + fileManager: fileManager 19 + ) 20 + 21 + #expect(resolved == nested.standardizedFileURL) 22 + } 23 + 24 + @Test func resolveSnapshotWorkingDirectoryAcceptsRelativePathWithinWorktree() throws { 25 + let fileManager = FileManager.default 26 + let worktreeRoot = try makeTemporaryDirectory(fileManager: fileManager) 27 + defer { try? fileManager.removeItem(at: worktreeRoot) } 28 + 29 + let nested = worktreeRoot.appending(path: "relative/path", directoryHint: .isDirectory) 30 + try fileManager.createDirectory(at: nested, withIntermediateDirectories: true) 31 + 32 + let resolved = WorktreeTerminalState.resolveSnapshotWorkingDirectory( 33 + from: "relative/path", 34 + worktreeRoot: worktreeRoot, 35 + fileManager: fileManager 36 + ) 37 + 38 + #expect(resolved == nested.standardizedFileURL) 39 + } 40 + 41 + @Test func resolveSnapshotWorkingDirectoryRejectsMissingOrOutsidePath() throws { 42 + let fileManager = FileManager.default 43 + let worktreeRoot = try makeTemporaryDirectory(fileManager: fileManager) 44 + let outsideRoot = try makeTemporaryDirectory(fileManager: fileManager) 45 + defer { 46 + try? fileManager.removeItem(at: worktreeRoot) 47 + try? fileManager.removeItem(at: outsideRoot) 48 + } 49 + 50 + let outsidePath = outsideRoot.appending(path: "outside", directoryHint: .isDirectory) 51 + try fileManager.createDirectory(at: outsidePath, withIntermediateDirectories: true) 52 + 53 + let missing = WorktreeTerminalState.resolveSnapshotWorkingDirectory( 54 + from: worktreeRoot.appending(path: "does-not-exist", directoryHint: .isDirectory) 55 + .path(percentEncoded: false), 56 + worktreeRoot: worktreeRoot, 57 + fileManager: fileManager 58 + ) 59 + let outside = WorktreeTerminalState.resolveSnapshotWorkingDirectory( 60 + from: outsidePath.path(percentEncoded: false), 61 + worktreeRoot: worktreeRoot, 62 + fileManager: fileManager 63 + ) 64 + 65 + #expect(missing == nil) 66 + #expect(outside == nil) 67 + } 68 + } 69 + 70 + private func makeTemporaryDirectory(fileManager: FileManager) throws -> URL { 71 + let url = fileManager.temporaryDirectory.appending( 72 + path: "worktree-terminal-state-tests-\(UUID().uuidString)", 73 + directoryHint: .isDirectory 74 + ) 75 + try fileManager.createDirectory(at: url, withIntermediateDirectories: true) 76 + return url 77 + }