diff --git a/supacode/Clients/Terminal/TerminalLayoutPersistenceClient.swift b/supacode/Clients/Terminal/TerminalLayoutPersistenceClient.swift index 93c09d47..7e73548b 100644 --- a/supacode/Clients/Terminal/TerminalLayoutPersistenceClient.swift +++ b/supacode/Clients/Terminal/TerminalLayoutPersistenceClient.swift @@ -68,18 +68,26 @@ nonisolated func loadTerminalLayoutSnapshot( at url: URL, fileManager: FileManager ) -> TerminalLayoutSnapshotPayload? { + let path = url.path(percentEncoded: false) + terminalLayoutPersistenceLogger.info("[LayoutRestore] load: path=\(path)") guard let data = try? Data(contentsOf: url) else { + terminalLayoutPersistenceLogger.info("[LayoutRestore] load: file not found or unreadable") return nil } + terminalLayoutPersistenceLogger.info("[LayoutRestore] load: read \(data.count) bytes") guard !data.isEmpty else { + terminalLayoutPersistenceLogger.info("[LayoutRestore] load: empty file, discarding") _ = discardTerminalLayoutSnapshot(at: url, fileManager: fileManager) return nil } guard let payload = TerminalLayoutSnapshotPayload.decodeValidated(from: data) else { - terminalLayoutPersistenceLogger.warning("Invalid terminal layout snapshot detected and reset") + terminalLayoutPersistenceLogger.warning("[LayoutRestore] load: invalid payload, discarding") _ = discardTerminalLayoutSnapshot(at: url, fileManager: fileManager) return nil } + terminalLayoutPersistenceLogger.info( + "[LayoutRestore] load: decoded \(payload.worktrees.count) worktree(s), version=\(payload.version)" + ) return payload } @@ -89,11 +97,15 @@ nonisolated func saveTerminalLayoutSnapshot( cacheDirectory: URL, fileManager: FileManager ) -> Bool { + terminalLayoutPersistenceLogger.info( + "[LayoutRestore] save: \(payload.worktrees.count) worktree(s) to \(snapshotURL.path(percentEncoded: false))" + ) guard payload.isValid else { - terminalLayoutPersistenceLogger.warning("Refusing to write invalid terminal layout snapshot") + terminalLayoutPersistenceLogger.warning("[LayoutRestore] save: payload is invalid, refusing to write") return false } if payload.worktrees.isEmpty { + terminalLayoutPersistenceLogger.info("[LayoutRestore] save: empty payload, discarding") return discardTerminalLayoutSnapshot(at: snapshotURL, fileManager: fileManager) } do { @@ -105,14 +117,15 @@ nonisolated func saveTerminalLayoutSnapshot( encoder.outputFormatting = [.prettyPrinted, .sortedKeys] let data = try encoder.encode(payload) guard data.count <= TerminalLayoutSnapshotPayload.maxSnapshotFileBytes else { - terminalLayoutPersistenceLogger.warning("Terminal layout snapshot exceeded size fuse and was skipped") + terminalLayoutPersistenceLogger.warning("[LayoutRestore] save: \(data.count) bytes exceeds fuse") return false } try data.write(to: snapshotURL, options: .atomic) + terminalLayoutPersistenceLogger.info("[LayoutRestore] save: wrote \(data.count) bytes successfully") return true } catch { terminalLayoutPersistenceLogger.warning( - "Unable to write terminal layout snapshot: \(error.localizedDescription)" + "[LayoutRestore] save: write failed: \(error.localizedDescription)" ) return false } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 22e9ebe1..c00fc24b 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -4,6 +4,8 @@ import Foundation import PostHog import SwiftUI +private let appLogger = SupaLogger("App") + private enum CancelID { static let periodicRefresh = "app.periodicRefresh" } @@ -149,6 +151,7 @@ struct AppFeature { .cancellable(id: CancelID.periodicRefresh, cancelInFlight: true) ) case .inactive, .background: + appLogger.info("[LayoutRestore] scenePhase=\(String(describing: phase)), saving layout snapshot") return .merge( .cancel(id: CancelID.periodicRefresh), .run { _ in @@ -256,11 +259,18 @@ struct AppFeature { !state.didAttemptTerminalLayoutRestore && state.settings.restoreTerminalLayoutOnLaunch && state.repositories.snapshotPersistencePhase == .active + appLogger.info( + "[LayoutRestore] repositoriesChanged: didAttempt=\(state.didAttemptTerminalLayoutRestore)" + + " settingEnabled=\(state.settings.restoreTerminalLayoutOnLaunch)" + + " phase=\(String(describing: state.repositories.snapshotPersistencePhase))" + + " → shouldRestore=\(shouldRestoreLayoutOnLaunch)" + ) if shouldRestoreLayoutOnLaunch { state.didAttemptTerminalLayoutRestore = true } state.runScriptStatusByWorktreeID = state.runScriptStatusByWorktreeID.filter { ids.contains($0.key) } let restorableWorktrees = makeTerminalRestorableWorktrees(from: Array(repositories)) + appLogger.info("[LayoutRestore] restorableWorktrees count=\(restorableWorktrees.count)") if case .repository(let repositoryID)? = state.settings.selection, !repositories.contains(where: { $0.id == repositoryID }) { diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 71ecbd9e..e7ce0e20 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -134,8 +134,10 @@ final class WorktreeTerminalManager { selectedWorktreeID = id terminalLogger.info("Selected worktree \(id ?? "nil")") case .saveLayoutSnapshot: + terminalLogger.info("[LayoutRestore] received saveLayoutSnapshot command") Task { await persistLayoutSnapshot() } case .restoreLayoutSnapshot(let worktrees): + terminalLogger.info("[LayoutRestore] received restoreLayoutSnapshot command, worktrees=\(worktrees.count)") Task { await restoreLayoutSnapshot(from: worktrees) } default: return @@ -395,24 +397,48 @@ final class WorktreeTerminalManager { func persistLayoutSnapshot() async { guard let payload = makeLayoutSnapshotPayload() else { + terminalLogger.info("[LayoutRestore] persist: no active states, clearing snapshot") _ = await layoutPersistence.clearSnapshot() return } - _ = await layoutPersistence.saveSnapshot(payload) + terminalLogger.info("[LayoutRestore] persist: saving \(payload.worktrees.count) worktree(s)") + let saved = await layoutPersistence.saveSnapshot(payload) + terminalLogger.info("[LayoutRestore] persist: save result=\(saved)") } func restoreLayoutSnapshot(from worktrees: [Worktree]) async { + terminalLogger.info("[LayoutRestore] restore: loading snapshot from disk") guard let payload = await layoutPersistence.loadSnapshot() else { + terminalLogger.info("[LayoutRestore] restore: no snapshot found on disk, skipping") return } + terminalLogger.info( + "[LayoutRestore] restore: loaded snapshot with \(payload.worktrees.count) worktree(s)," + + " available worktrees=\(worktrees.count)" + ) + for (i, sw) in payload.worktrees.enumerated() { + terminalLogger.info( + "[LayoutRestore] restore: snapshot[\(i)] worktreeID=\(sw.worktreeID)" + + " tabs=\(sw.tabs.count) selectedTab=\(sw.selectedTabID ?? "nil")" + ) + } + for (i, w) in worktrees.enumerated() { + terminalLogger.info("[LayoutRestore] restore: available[\(i)] id=\(w.id) name=\(w.name)") + } let didRestore = applyLayoutSnapshotPayload(payload, availableWorktrees: worktrees) + terminalLogger.info("[LayoutRestore] restore: applyResult=\(didRestore)") if !didRestore { + terminalLogger.info("[LayoutRestore] restore: clearing invalid snapshot") _ = await layoutPersistence.clearSnapshot() } } private func makeLayoutSnapshotPayload() -> TerminalLayoutSnapshotPayload? { let activeStates = activeWorktreeStates.sorted { $0.worktreeID < $1.worktreeID } + terminalLogger.info( + "[LayoutRestore] makePayload: activeWorktreeStates=\(activeStates.count)" + + " totalStates=\(states.count)" + ) guard !activeStates.isEmpty else { return nil } @@ -421,6 +447,9 @@ final class WorktreeTerminalManager { snapshotWorktrees.reserveCapacity(activeStates.count) for state in activeStates { guard let snapshot = state.makeLayoutSnapshotWorktree() else { + terminalLogger.warning( + "[LayoutRestore] makePayload: failed to snapshot worktree \(state.worktreeID)" + ) return nil } snapshotWorktrees.append(snapshot) @@ -438,13 +467,18 @@ final class WorktreeTerminalManager { for snapshot in payload.worktrees { guard let worktree = worktreeByID[snapshot.worktreeID] else { + terminalLogger.warning( + "[LayoutRestore] apply: worktreeID \(snapshot.worktreeID) not found in available worktrees" + ) for state in restoredStates { state.closeAllSurfaces() } return false } + terminalLogger.info("[LayoutRestore] apply: restoring worktree \(worktree.id)") let state = state(for: worktree) guard state.applyLayoutSnapshot(snapshot) else { + terminalLogger.warning("[LayoutRestore] apply: applyLayoutSnapshot failed for \(worktree.id)") state.closeAllSurfaces() for restored in restoredStates { restored.closeAllSurfaces() @@ -454,6 +488,7 @@ final class WorktreeTerminalManager { restoredStates.append(state) } + terminalLogger.info("[LayoutRestore] apply: successfully restored \(restoredStates.count) worktree(s)") return true } } diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 64bceedc..0cb8eaf0 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -5,6 +5,8 @@ import GhosttyKit import Observation import Sharing +private let terminalStateLogger = SupaLogger("TerminalState") + @MainActor @Observable final class WorktreeTerminalState { @@ -555,7 +557,11 @@ final class WorktreeTerminalState { } func makeLayoutSnapshotWorktree() -> TerminalLayoutSnapshotPayload.SnapshotWorktree? { + terminalStateLogger.info( + "[LayoutRestore] makeSnapshot: worktree=\(worktree.id) tabs=\(tabManager.tabs.count)" + ) guard !tabManager.tabs.isEmpty else { + terminalStateLogger.info("[LayoutRestore] makeSnapshot: no tabs, returning nil") return nil } @@ -563,9 +569,15 @@ final class WorktreeTerminalState { snapshotTabs.reserveCapacity(tabManager.tabs.count) for tab in tabManager.tabs { guard let tree = trees[tab.id], let root = tree.root else { + terminalStateLogger.warning( + "[LayoutRestore] makeSnapshot: no tree/root for tab \(tab.id.rawValue.uuidString)" + ) return nil } guard let splitRoot = makeLayoutSnapshotNode(from: root) else { + terminalStateLogger.warning( + "[LayoutRestore] makeSnapshot: failed to snapshot split tree for tab \(tab.id.rawValue.uuidString)" + ) return nil } snapshotTabs.append( @@ -576,15 +588,24 @@ final class WorktreeTerminalState { ) } - return TerminalLayoutSnapshotPayload.SnapshotWorktree( + let result = TerminalLayoutSnapshotPayload.SnapshotWorktree( worktreeID: worktree.id, selectedTabID: tabManager.selectedTabId?.rawValue.uuidString, tabs: snapshotTabs ) + terminalStateLogger.info( + "[LayoutRestore] makeSnapshot: success, \(snapshotTabs.count) tab(s) captured" + ) + return result } func applyLayoutSnapshot(_ snapshot: TerminalLayoutSnapshotPayload.SnapshotWorktree) -> Bool { + terminalStateLogger.info( + "[LayoutRestore] applySnapshot: worktree=\(worktree.id)" + + " snapshotWorktreeID=\(snapshot.worktreeID) tabs=\(snapshot.tabs.count)" + ) guard snapshot.worktreeID == worktree.id else { + terminalStateLogger.warning("[LayoutRestore] applySnapshot: worktreeID mismatch") return false } @@ -595,13 +616,17 @@ final class WorktreeTerminalState { for (index, snapshotTab) in snapshot.tabs.enumerated() { guard let tabUUID = UUID(uuidString: snapshotTab.tabID) else { + terminalStateLogger.warning("[LayoutRestore] applySnapshot: invalid tab UUID \(snapshotTab.tabID)") return false } let tabID = TerminalTabID(rawValue: tabUUID) guard seenTabIDs.insert(tabID).inserted else { + terminalStateLogger.warning("[LayoutRestore] applySnapshot: duplicate tab ID \(snapshotTab.tabID)") return false } + terminalStateLogger.info("[LayoutRestore] applySnapshot: restoring tab[\(index)] id=\(snapshotTab.tabID)") guard let rootNode = restoreSplitNode(from: snapshotTab.splitRoot, tabID: tabID, isRoot: true) else { + terminalStateLogger.warning("[LayoutRestore] applySnapshot: restoreSplitNode failed for tab[\(index)]") closeAllSurfaces() return false } @@ -620,11 +645,13 @@ final class WorktreeTerminalState { let selectedTabID: TerminalTabID? if let selectedTabRaw = snapshot.selectedTabID { guard let selectedUUID = UUID(uuidString: selectedTabRaw) else { + terminalStateLogger.warning("[LayoutRestore] applySnapshot: invalid selectedTab UUID \(selectedTabRaw)") closeAllSurfaces() return false } let candidate = TerminalTabID(rawValue: selectedUUID) guard seenTabIDs.contains(candidate) else { + terminalStateLogger.warning("[LayoutRestore] applySnapshot: selectedTab not in restored tabs") closeAllSurfaces() return false } @@ -646,6 +673,10 @@ final class WorktreeTerminalState { lastEmittedFocusSurfaceId = nil } emitTaskStatusIfChanged() + terminalStateLogger.info( + "[LayoutRestore] applySnapshot: success, restored \(restoredTabs.count) tab(s)" + + " selectedTab=\(selectedTabID?.rawValue.uuidString ?? "nil")" + ) return true }