diff --git a/App/Sources/Clients/Terminal/TerminalClient.swift b/App/Sources/Clients/Terminal/TerminalClient.swift index b2ad68637..a93421f80 100644 --- a/App/Sources/Clients/Terminal/TerminalClient.swift +++ b/App/Sources/Clients/Terminal/TerminalClient.swift @@ -74,9 +74,8 @@ struct TerminalClient { case setRemoteAgentDetection(RemoteAgentDetectionConsent) case setSelectedWorktreeID(Worktree.ID?) case saveLayoutSnapshot - /// `allowsMissingWorktrees` skips the saved worktrees that `worktrees` does not hold - /// instead of resetting the whole layout; a failed repository load leaves them out. - case restoreLayoutSnapshot(worktrees: [Worktree], allowsMissingWorktrees: Bool = false) + /// Saved worktrees that `worktrees` does not hold are skipped. + case restoreLayoutSnapshot(worktrees: [Worktree]) case presentTabIconPicker(Worktree) } diff --git a/App/Sources/Features/App/Reducer/AppFeature+Support.swift b/App/Sources/Features/App/Reducer/AppFeature+Support.swift index 57b2db3a9..59caf2203 100644 --- a/App/Sources/Features/App/Reducer/AppFeature+Support.swift +++ b/App/Sources/Features/App/Reducer/AppFeature+Support.swift @@ -282,7 +282,7 @@ extension AppFeature { { effects.append(defaultViewSelectionEffect(for: targetID, repositories: state.repositories)) } - effects.append(.send(.repositories(.toggleCanvas))) + effects.append(.send(.repositories(.enterCanvasAtLaunch))) } return effects.isEmpty ? .none : .concatenate(effects) } @@ -291,6 +291,7 @@ extension AppFeature { let candidateIDs = [repositories.lastFocusedWorktreeID].compactMap(\.self) + repositories.orderedWorktreeRows().map(\.id) + + repositories.orderedRepositoryIDs() return candidateIDs.first { id in repositories.worktree(for: id) != nil || repositories.repositories[id: id]?.kind == .plain diff --git a/App/Sources/Features/App/Reducer/AppFeature+TerminalEvents.swift b/App/Sources/Features/App/Reducer/AppFeature+TerminalEvents.swift index 7d7295625..1e88b8618 100644 --- a/App/Sources/Features/App/Reducer/AppFeature+TerminalEvents.swift +++ b/App/Sources/Features/App/Reducer/AppFeature+TerminalEvents.swift @@ -111,9 +111,10 @@ extension AppFeature { case .layoutRestoreFailed(let message): appLogger.warning("[LayoutRestore] layoutRestoreFailed: \(message)") + let fallbackSelection = layoutRestoreFallbackSelectionEffect(state: state) return .merge( .send(.repositories(.showToast(.warning(message)))), - applyDefaultViewMode(into: &state) + .concatenate(fallbackSelection, applyDefaultViewMode(into: &state)) ) default: @@ -288,10 +289,24 @@ extension AppFeature { } else { effects.append(.send(.repositories(.selectWorktree(selectedWorktreeID)))) } + } else { + effects.append(layoutRestoreFallbackSelectionEffect(state: state)) } return .concatenate([.merge(effects), applyDefaultViewMode(into: &state)]) } + /// Restore clears the selection at launch, so a layout that names no worktree must select one + /// the same way a launch without restore does. The Shelf and Canvas default views select their + /// own anchor. + func layoutRestoreFallbackSelectionEffect(state: State) -> Effect { + @Shared(.settingsFile) var settingsFile + guard state.repositories.selection == nil, + settingsFile.global.defaultViewMode == .normal, + let targetID = defaultViewFallbackSelectionID(in: state.repositories) + else { return .none } + return defaultViewSelectionEffect(for: targetID, repositories: state.repositories) + } + func tabCreatedEffect( worktreeID: Worktree.ID, state: State diff --git a/App/Sources/Features/App/Reducer/AppFeature.swift b/App/Sources/Features/App/Reducer/AppFeature.swift index fb40dff98..b7eff3dfe 100644 --- a/App/Sources/Features/App/Reducer/AppFeature.swift +++ b/App/Sources/Features/App/Reducer/AppFeature.swift @@ -343,7 +343,6 @@ struct AppFeature { ids.contains($0.key) } let restorableWorktrees = makeTerminalRestorableWorktrees(from: Array(repositories)) - let allowsMissingWorktrees = !failedRepositoryIDs.isEmpty appLogger.info("[LayoutRestore] restorableWorktrees count=\(restorableWorktrees.count)") var allEffects: [Effect] = [ // Runs a previous app instance left behind are marked interrupted (dsl-spec §10 Restart). @@ -377,9 +376,7 @@ struct AppFeature { if shouldRestoreLayout { effects.append( .run { _ in - await terminalClient.send( - .restoreLayoutSnapshot( - worktrees: restorableWorktrees, allowsMissingWorktrees: allowsMissingWorktrees)) + await terminalClient.send(.restoreLayoutSnapshot(worktrees: restorableWorktrees)) } ) } @@ -405,9 +402,7 @@ struct AppFeature { if shouldRestoreLayout { effects.append( .run { _ in - await terminalClient.send( - .restoreLayoutSnapshot( - worktrees: restorableWorktrees, allowsMissingWorktrees: allowsMissingWorktrees)) + await terminalClient.send(.restoreLayoutSnapshot(worktrees: restorableWorktrees)) } ) } diff --git a/App/Sources/Features/Canvas/Models/CanvasFocusRequest.swift b/App/Sources/Features/Canvas/Models/CanvasFocusRequest.swift index 187ebd33b..862af3b2a 100644 --- a/App/Sources/Features/Canvas/Models/CanvasFocusRequest.swift +++ b/App/Sources/Features/Canvas/Models/CanvasFocusRequest.swift @@ -5,6 +5,8 @@ struct CanvasFocusRequest: Equatable, Sendable { enum Target: Equatable, Sendable { case worktree(Worktree.ID) case tab(TerminalTabID) + /// The tab that the worktree has selected, as Normal view would show it. + case selectedTabInWorktree(Worktree.ID) } /// How the viewport follows the focused card. @@ -57,9 +59,17 @@ enum CanvasFocusResolver { static func resolve( request: CanvasFocusRequest, candidates: [CanvasCardRef], - currentPrimaryTabID: TerminalTabID? + currentPrimaryTabID: TerminalTabID?, + selectedTabID: (Worktree.ID) -> TerminalTabID? = { _ in nil } ) -> TerminalTabID? { switch request.target { + case .selectedTabInWorktree(let worktreeID): + let matchingTabIDs = candidates.filter { $0.worktreeID == worktreeID }.map(\.tabID) + if let selected = selectedTabID(worktreeID), matchingTabIDs.contains(selected) { + return selected + } + return matchingTabIDs.first + case .tab(let tabID): return candidates.contains { $0.tabID == tabID } ? tabID : nil diff --git a/App/Sources/Features/Canvas/Views/CanvasView+Focus.swift b/App/Sources/Features/Canvas/Views/CanvasView+Focus.swift index 4aaf9e4d4..467476484 100644 --- a/App/Sources/Features/Canvas/Views/CanvasView+Focus.swift +++ b/App/Sources/Features/Canvas/Views/CanvasView+Focus.swift @@ -60,7 +60,10 @@ extension CanvasView { let tabID = CanvasFocusResolver.resolve( request: request, candidates: canvasCards(in: states), - currentPrimaryTabID: selectionState.primaryTabID + currentPrimaryTabID: selectionState.primaryTabID, + selectedTabID: { worktreeID in + states.first { $0.worktreeID == worktreeID }?.tabManager.selectedTabId + } ) guard let tabID else { return } focusSingleCard(tabID, states: states) diff --git a/App/Sources/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift b/App/Sources/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift index 1839d28ab..c09ec8407 100644 --- a/App/Sources/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift +++ b/App/Sources/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift @@ -512,8 +512,8 @@ extension RepositoriesFeature { } return .send(.selectWorktree(targetID, focusTerminal: true)) } else { - // Enter canvas if there are any open worktrees. - guard !state.orderedWorktreeRows().isEmpty else { return .none } + // Canvas has the same requirement as Shelf: a worktree or a folder. + guard state.canEnterShelf else { return .none } return .send(.selectCanvas) } @@ -776,6 +776,16 @@ extension RepositoriesFeature { guard let id = state.worktreeID(byOffset: -1) else { return .none } return .send(.selectWorktree(id, focusTerminal: true)) + // A restored terminal can hold first responder while no card is primary, so launching into + // Canvas names the anchor. `.reveal` keeps the board the user last saw instead of zooming in. + case .enterCanvasAtLaunch: + guard state.canEnterShelf else { return .none } + if let anchorID = state.selectedTerminalWorktree?.id { + requestCanvasFocus( + .selectedTabInWorktree(anchorID), viewport: .reveal, openedWorktreeID: anchorID, state: &state) + } + return .send(.selectCanvas) + case .consumeCanvasFocusRequest(let id): if state.pendingCanvasFocusRequest?.id == id { state.pendingCanvasFocusRequest = nil diff --git a/App/Sources/Features/Repositories/Reducer/RepositoriesFeature.swift b/App/Sources/Features/Repositories/Reducer/RepositoriesFeature.swift index eb47f87ff..a3495a0d6 100644 --- a/App/Sources/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/App/Sources/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -604,6 +604,7 @@ struct RepositoriesFeature { case focusCanvasWorktree(Worktree.ID) case selectNextWorktree case selectPreviousWorktree + case enterCanvasAtLaunch case consumeCanvasFocusRequest(Int) case requestCanvasCommand(CanvasCommandRequest.Command) case consumeCanvasCommandRequest(Int) diff --git a/App/Sources/Features/Settings/Models/GlobalSettings.swift b/App/Sources/Features/Settings/Models/GlobalSettings.swift index f04ecfb18..5082b7b92 100644 --- a/App/Sources/Features/Settings/Models/GlobalSettings.swift +++ b/App/Sources/Features/Settings/Models/GlobalSettings.swift @@ -78,7 +78,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { copyIgnoredOnWorktreeCreate: false, copyUntrackedOnWorktreeCreate: false, pullRequestMergeStrategy: .merge, - restoreTerminalLayoutOnLaunch: false, + restoreTerminalLayoutOnLaunch: true, archivedAutoDeletePeriod: nil, terminalFontSize: nil, keybindingUserOverrides: .empty, @@ -127,7 +127,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { copyIgnoredOnWorktreeCreate: Bool = false, copyUntrackedOnWorktreeCreate: Bool = false, pullRequestMergeStrategy: PullRequestMergeStrategy = .merge, - restoreTerminalLayoutOnLaunch: Bool = false, + restoreTerminalLayoutOnLaunch: Bool = true, archivedAutoDeletePeriod: AutoDeletePeriod? = nil, terminalFontSize: Float32? = nil, keybindingUserOverrides: KeybindingUserOverrideStore = .empty, @@ -281,7 +281,9 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { case copyIgnoredOnWorktreeCreate case copyUntrackedOnWorktreeCreate case pullRequestMergeStrategy - case restoreTerminalLayoutOnLaunch + // A new key turns this on once for every file that stored the old `restoreTerminalLayoutOnLaunch`, + // because that key stored `false` for anyone who kept the old default. + case restoreTerminalLayoutOnLaunch = "restoreTerminalLayout" case archivedAutoDeletePeriod case terminalFontSize case keybindingUserOverrides diff --git a/App/Sources/Features/Settings/Views/AdvancedSettingsView.swift b/App/Sources/Features/Settings/Views/AdvancedSettingsView.swift index d100a1d41..5e5002646 100644 --- a/App/Sources/Features/Settings/Views/AdvancedSettingsView.swift +++ b/App/Sources/Features/Settings/Views/AdvancedSettingsView.swift @@ -37,24 +37,6 @@ struct AdvancedSettingsView: View { } .frame(maxWidth: .infinity, alignment: .leading) } - - Section("Terminal Layout") { - VStack(alignment: .leading, spacing: 8) { - Toggle( - "Restore terminal layout on launch (experimental)", - isOn: $store.restoreTerminalLayoutOnLaunch - ) - Text("When enabled, Prowl attempts to restore tabs and splits after restart.") - .foregroundStyle(.secondary) - .font(.callout) - Button("Clear saved terminal layout") { - store.send(.clearTerminalLayoutSnapshotButtonTapped) - } - .help("Remove the saved terminal tab and split layout from disk") - .buttonStyle(.bordered) - } - .frame(maxWidth: .infinity, alignment: .leading) - } } .formStyle(.grouped) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) diff --git a/App/Sources/Features/Settings/Views/AppearanceSettingsView.swift b/App/Sources/Features/Settings/Views/AppearanceSettingsView.swift index ee96f0735..c152deda7 100644 --- a/App/Sources/Features/Settings/Views/AppearanceSettingsView.swift +++ b/App/Sources/Features/Settings/Views/AppearanceSettingsView.swift @@ -143,6 +143,17 @@ struct AppearanceSettingsView: View { .foregroundStyle(.secondary) .font(.callout) } + Section("Terminal Layout") { + Toggle(isOn: $store.restoreTerminalLayoutOnLaunch) { + Text("Restore terminal layout on launch") + Text("Reopens your tabs and splits in their folders. Commands that were running do not start again.") + } + .help("Reopen the saved terminal tabs and splits when Prowl starts") + Button("Clear saved terminal layout") { + store.send(.clearTerminalLayoutSnapshotButtonTapped) + } + .help("Remove the saved terminal tab and split layout from disk") + } Section("Default Editor") { Toggle( "Show in toolbar", diff --git a/App/Sources/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/App/Sources/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 4fcb41830..47b419017 100644 --- a/App/Sources/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/App/Sources/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -563,11 +563,10 @@ final class WorktreeTerminalManager { case .saveLayoutSnapshot: terminalLogger.info("[LayoutRestore] received saveLayoutSnapshot command") Task { await persistLayoutSnapshot() } - case .restoreLayoutSnapshot(let worktrees, let allowsMissingWorktrees): + case .restoreLayoutSnapshot(let worktrees): terminalLogger.info( - "[LayoutRestore] received restoreLayoutSnapshot command, worktrees=\(worktrees.count)" - + " allowsMissing=\(allowsMissingWorktrees)") - Task { await restoreLayoutSnapshot(from: worktrees, allowsMissingWorktrees: allowsMissingWorktrees) } + "[LayoutRestore] received restoreLayoutSnapshot command, worktrees=\(worktrees.count)") + Task { await restoreLayoutSnapshot(from: worktrees) } case .presentTabIconPicker(let worktree): state(for: worktree).presentIconPickerForFocusedTab() default: @@ -1581,7 +1580,7 @@ final class WorktreeTerminalManager { terminalLogger.info("[LayoutRestore] persistSync: save result=\(saved)") } - func restoreLayoutSnapshot(from worktrees: [Worktree], allowsMissingWorktrees: Bool = false) async { + 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") @@ -1604,8 +1603,7 @@ final class WorktreeTerminalManager { } let skippedWorktreeIDs = applyLayoutSnapshotPayload( payload, - availableWorktrees: worktrees, - allowsMissingWorktrees: allowsMissingWorktrees + availableWorktrees: worktrees ) terminalLogger.info("[LayoutRestore] restore: applyResult=\(skippedWorktreeIDs != nil)") if let skippedWorktreeIDs { @@ -1665,8 +1663,7 @@ final class WorktreeTerminalManager { /// snapshot is invalid and nothing was restored. private func applyLayoutSnapshotPayload( _ payload: TerminalLayoutSnapshotPayload, - availableWorktrees: [Worktree], - allowsMissingWorktrees: Bool + availableWorktrees: [Worktree] ) -> Set? { let worktreeByID = Dictionary( availableWorktrees.map { ($0.id, $0) }, @@ -1689,18 +1686,14 @@ final class WorktreeTerminalManager { } continue } + // A saved worktree can be gone at launch: it was removed outside Prowl, or its repository + // failed to load. The rest of the layout is still valid. guard let worktree = worktreeByID[snapshot.worktreeID] else { terminalLogger.warning( "[LayoutRestore] apply: worktreeID \(snapshot.worktreeID) not found in available worktrees" ) - if allowsMissingWorktrees { - skippedWorktreeIDs.insert(snapshot.worktreeID) - continue - } - for state in restoredStates { - state.closeAllSurfaces() - } - return nil + skippedWorktreeIDs.insert(snapshot.worktreeID) + continue } terminalLogger.info("[LayoutRestore] apply: restoring worktree \(worktree.id)") let state = state(for: worktree) diff --git a/App/Sources/Infrastructure/Ghostty/GhosttySurfaceView.swift b/App/Sources/Infrastructure/Ghostty/GhosttySurfaceView.swift index 63caec908..1ce1ab718 100644 --- a/App/Sources/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/App/Sources/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -959,24 +959,32 @@ final class GhosttySurfaceView: NSView, Identifiable { static func moveFocus( to view: GhosttySurfaceView, from previous: GhosttySurfaceView? = nil, - delay: TimeInterval? = nil + clock: any Clock = ContinuousClock(), + requests: FocusRequests = .shared ) { - let maxDelay: TimeInterval = 0.5 - let currentDelay = delay ?? 0 - guard currentDelay < maxDelay else { return } - let nextDelay: TimeInterval = if let delay { delay * 2 } else { 0.05 } + requests.latest &+= 1 + let request = requests.latest Task { @MainActor in - if let delay { - try? await ContinuousClock().sleep(for: .seconds(delay)) - } - guard let window = view.window else { - moveFocus(to: view, from: previous, delay: nextDelay) - return - } - if let previous, previous !== view { - _ = previous.resignFirstResponder() + var delay: TimeInterval = 0.05 + while request == requests.latest { + if let window = view.window { + if let previous, previous !== view { + _ = previous.resignFirstResponder() + } + window.makeFirstResponder(view) + return + } + guard delay < 0.5 else { return } + try? await clock.sleep(for: .seconds(delay)) + delay *= 2 } - window.makeFirstResponder(view) } } + + /// A request retries until its view has a window, so the newest request must cancel older ones: + /// a retry that lands later would move first responder away from the surface that shows focus. + final class FocusRequests { + static let shared = FocusRequests() + fileprivate var latest = 0 + } } diff --git a/App/Tests/AppFeatureTerminalLayoutRestoreTests.swift b/App/Tests/AppFeatureTerminalLayoutRestoreTests.swift index cc6137247..ea7807bd2 100644 --- a/App/Tests/AppFeatureTerminalLayoutRestoreTests.swift +++ b/App/Tests/AppFeatureTerminalLayoutRestoreTests.swift @@ -124,7 +124,7 @@ struct AppFeatureTerminalLayoutRestoreTests { #expect( sentCommands.value.contains( - .restoreLayoutSnapshot(worktrees: [worktree], allowsMissingWorktrees: true) + .restoreLayoutSnapshot(worktrees: [worktree]) ) ) } @@ -134,10 +134,12 @@ struct AppFeatureTerminalLayoutRestoreTests { let repository = makeRepository(worktrees: [worktree]) var repositoriesState = RepositoriesFeature.State(repositories: [repository]) repositoriesState.snapshotPersistencePhase = .active + var settings = SettingsFeature.State() + settings.restoreTerminalLayoutOnLaunch = false let sentCommands = LockIsolated<[TerminalClient.Command]>([]) let store = TestStore( - initialState: AppFeature.State(repositories: repositoriesState, settings: SettingsFeature.State()) + initialState: AppFeature.State(repositories: repositoriesState, settings: settings) ) { AppFeature() } withDependencies: { @@ -282,9 +284,11 @@ struct AppFeatureTerminalLayoutRestoreTests { repositoriesState.selection = .worktree(worktree.id) setDefaultViewMode(.shelf) defer { setDefaultViewMode(.normal) } + var settings = SettingsFeature.State() + settings.restoreTerminalLayoutOnLaunch = false let store = TestStore( - initialState: AppFeature.State(repositories: repositoriesState) + initialState: AppFeature.State(repositories: repositoriesState, settings: settings) ) { AppFeature() } withDependencies: { @@ -311,10 +315,12 @@ struct AppFeatureTerminalLayoutRestoreTests { repositoriesState.selection = .worktree(worktree.id) setDefaultViewMode(.canvas) defer { setDefaultViewMode(.normal) } + var settings = SettingsFeature.State() + settings.restoreTerminalLayoutOnLaunch = false let sentCommands = LockIsolated<[TerminalClient.Command]>([]) let store = TestStore( - initialState: AppFeature.State(repositories: repositoriesState) + initialState: AppFeature.State(repositories: repositoriesState, settings: settings) ) { AppFeature() } withDependencies: { @@ -328,7 +334,7 @@ struct AppFeatureTerminalLayoutRestoreTests { await store.send(.repositories(.delegate(.repositoriesChanged([repository])))) { $0.hasAppliedInitialViewMode = true } - await store.receive(\.repositories.toggleCanvas) + await store.receive(\.repositories.enterCanvasAtLaunch) await store.receive(\.repositories.selectCanvas) { $0.repositories.preCanvasWorktreeID = worktree.id $0.repositories.preCanvasTerminalTargetID = worktree.id @@ -371,7 +377,7 @@ struct AppFeatureTerminalLayoutRestoreTests { $0.repositories.selection = .worktree(worktree.id) $0.repositories.openedWorktreeIDs = [worktree.id] } - await store.receive(\.repositories.toggleCanvas) + await store.receive(\.repositories.enterCanvasAtLaunch) await store.receive(\.repositories.selectCanvas) { $0.repositories.preCanvasWorktreeID = worktree.id $0.repositories.preCanvasTerminalTargetID = worktree.id @@ -386,6 +392,223 @@ struct AppFeatureTerminalLayoutRestoreTests { ) } + @Test(.dependencies) func layoutRestoredNilSelectsLastFocusedWorktree() async { + let worktree = makeWorktree() + let repository = makeRepository(worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State(repositories: [repository]) + repositoriesState.lastFocusedWorktreeID = worktree.id + repositoriesState.selection = nil + setDefaultViewMode(.normal) + + let store = TestStore( + initialState: AppFeature.State(repositories: repositoriesState) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { _ in } + } + store.exhaustivity = .off + + await store.send(.terminalEvent(.layoutRestored(selectedWorktreeID: nil))) + await store.receive(\.repositories.selectWorktree) { + $0.repositories.selection = .worktree(worktree.id) + } + } + + @Test(.dependencies) func layoutRestoredNilSelectsAvailableWorktreeWhenLastFocusedIsGone() async { + let worktree = makeWorktree() + let repository = makeRepository(worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State(repositories: [repository]) + repositoriesState.lastFocusedWorktreeID = "/tmp/removed/worktree" + repositoriesState.selection = nil + setDefaultViewMode(.normal) + + let store = TestStore( + initialState: AppFeature.State(repositories: repositoriesState) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { _ in } + } + store.exhaustivity = .off + + await store.send(.terminalEvent(.layoutRestored(selectedWorktreeID: nil))) + await store.receive(\.repositories.selectWorktree) { + $0.repositories.selection = .worktree(worktree.id) + } + } + + @Test(.dependencies) func layoutRestoreFailedSelectsLastFocusedWorktree() async { + let worktree = makeWorktree() + let repository = makeRepository(worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State(repositories: [repository]) + repositoriesState.lastFocusedWorktreeID = worktree.id + repositoriesState.selection = nil + setDefaultViewMode(.normal) + + let store = TestStore( + initialState: AppFeature.State(repositories: repositoriesState) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { _ in } + } + store.exhaustivity = .off + + await store.send(.terminalEvent(.layoutRestoreFailed(message: "reset"))) + await store.receive(\.repositories.selectWorktree) { + $0.repositories.selection = .worktree(worktree.id) + } + } + + @Test(.dependencies) func layoutRestoredNilSelectsPlainFolderWhenNoWorktreeIsAvailable() async { + let plainRepository = makePlainRepository() + var repositoriesState = RepositoriesFeature.State(repositories: [plainRepository]) + repositoriesState.lastFocusedWorktreeID = "/tmp/removed/worktree" + repositoriesState.selection = nil + setDefaultViewMode(.normal) + + let store = TestStore( + initialState: AppFeature.State(repositories: repositoriesState) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { _ in } + } + store.exhaustivity = .off + + await store.send(.terminalEvent(.layoutRestored(selectedWorktreeID: nil))) + await store.receive(\.repositories.selectRepository) + } + + @Test(.dependencies) func layoutRestoreFailedSelectsPlainFolderWhenNoWorktreeIsAvailable() async { + let plainRepository = makePlainRepository() + var repositoriesState = RepositoriesFeature.State(repositories: [plainRepository]) + repositoriesState.selection = nil + setDefaultViewMode(.normal) + + let store = TestStore( + initialState: AppFeature.State(repositories: repositoriesState) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { _ in } + } + store.exhaustivity = .off + + await store.send(.terminalEvent(.layoutRestoreFailed(message: "reset"))) + await store.receive(\.repositories.selectRepository) + } + + @Test(.dependencies) func layoutRestoredNilPrefersWorktreeOverPlainFolder() async { + let worktree = makeWorktree() + let repository = makeRepository(worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State(repositories: [makePlainRepository(), repository]) + repositoriesState.selection = nil + setDefaultViewMode(.normal) + + let store = TestStore( + initialState: AppFeature.State(repositories: repositoriesState) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { _ in } + } + store.exhaustivity = .off + + await store.send(.terminalEvent(.layoutRestored(selectedWorktreeID: nil))) + await store.receive(\.repositories.selectWorktree) { + $0.repositories.selection = .worktree(worktree.id) + } + } + + @Test(.dependencies) func layoutRestoredDefaultCanvasFocusesSelectedWorktree() async { + let worktree = makeWorktree() + let repository = makeRepository(worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State(repositories: [repository]) + repositoriesState.selection = nil + setDefaultViewMode(.canvas) + defer { setDefaultViewMode(.normal) } + + let store = TestStore( + initialState: AppFeature.State(repositories: repositoriesState) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { _ in } + } + store.exhaustivity = .off + + await store.send(.terminalEvent(.layoutRestored(selectedWorktreeID: worktree.id))) + await store.finish() + await store.skipReceivedActions() + + #expect(store.state.repositories.selection == .canvas) + let request = store.state.repositories.pendingCanvasFocusRequest + #expect(request?.target == .selectedTabInWorktree(worktree.id)) + #expect(request?.viewport == .reveal) + } + + @Test(.dependencies) func repositoriesChangedDefaultCanvasFocusesAnchorWithoutRestore() async { + let worktree = makeWorktree() + let repository = makeRepository(worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State(repositories: [repository]) + repositoriesState.selection = .worktree(worktree.id) + setDefaultViewMode(.canvas) + defer { setDefaultViewMode(.normal) } + var settings = SettingsFeature.State() + settings.restoreTerminalLayoutOnLaunch = false + + let store = TestStore( + initialState: AppFeature.State(repositories: repositoriesState, settings: settings) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { _ in } + $0.worktreeInfoWatcher.send = { _ in } + } + store.exhaustivity = .off + + await store.send(.repositories(.delegate(.repositoriesChanged([repository])))) + await store.finish() + await store.skipReceivedActions() + + #expect(store.state.repositories.pendingCanvasFocusRequest?.target == .selectedTabInWorktree(worktree.id)) + } + + @Test(.dependencies) func layoutRestoredDefaultCanvasEntersWithOnlyPlainFolder() async { + let plainRepository = makePlainRepository() + var repositoriesState = RepositoriesFeature.State(repositories: [plainRepository]) + repositoriesState.selection = nil + setDefaultViewMode(.canvas) + defer { setDefaultViewMode(.normal) } + let sentCommands = LockIsolated<[TerminalClient.Command]>([]) + + let store = TestStore( + initialState: AppFeature.State(repositories: repositoriesState) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sentCommands.withValue { $0.append(command) } + } + } + store.exhaustivity = .off + + await store.send(.terminalEvent(.layoutRestored(selectedWorktreeID: nil))) + await store.finish() + await store.skipReceivedActions() + + #expect(store.state.repositories.selection == .canvas) + #expect(store.state.repositories.pendingCanvasFocusRequest?.target == .selectedTabInWorktree(plainRepository.id)) + #expect( + sentCommands.value.contains { command in + if case .ensureInitialTab(let worktree, _, _) = command { return worktree.id == plainRepository.id } + return false + } + ) + } + @Test(.dependencies) func layoutRestoreFailedAppliesDefaultShelf() async { let worktree = makeWorktree() let repository = makeRepository(worktrees: [worktree]) diff --git a/App/Tests/CanvasFocusResolverTests.swift b/App/Tests/CanvasFocusResolverTests.swift index 12723aea8..fc78c072f 100644 --- a/App/Tests/CanvasFocusResolverTests.swift +++ b/App/Tests/CanvasFocusResolverTests.swift @@ -121,4 +121,35 @@ struct CanvasNewCardFocusTests { #expect(card == nil) } + + @Test func selectedTabTargetUsesTheWorktreeSelectedTab() { + let first = TerminalTabID(rawValue: UUID()) + let second = TerminalTabID(rawValue: UUID()) + let other = TerminalTabID(rawValue: UUID()) + let candidates = [ + CanvasCardRef(worktreeID: "other", tabID: other), + CanvasCardRef(worktreeID: "anchor", tabID: first), + CanvasCardRef(worktreeID: "anchor", tabID: second), + ] + let request = CanvasFocusRequest(id: 1, target: .selectedTabInWorktree("anchor")) + + let selected = CanvasFocusResolver.resolve( + request: request, candidates: candidates, currentPrimaryTabID: nil, selectedTabID: { _ in second }) + #expect(selected == second) + + let noSelection = CanvasFocusResolver.resolve( + request: request, candidates: candidates, currentPrimaryTabID: nil, selectedTabID: { _ in nil }) + #expect(noSelection == first) + + let selectedElsewhere = CanvasFocusResolver.resolve( + request: request, candidates: candidates, currentPrimaryTabID: nil, selectedTabID: { _ in other }) + #expect(selectedElsewhere == first) + } + + @Test func selectedTabTargetWaitsForTheWorktreeCard() { + let candidates = [CanvasCardRef(worktreeID: "other", tabID: TerminalTabID(rawValue: UUID()))] + let request = CanvasFocusRequest(id: 1, target: .selectedTabInWorktree("anchor")) + + #expect(CanvasFocusResolver.resolve(request: request, candidates: candidates, currentPrimaryTabID: nil) == nil) + } } diff --git a/App/Tests/GhosttySurfaceViewTests.swift b/App/Tests/GhosttySurfaceViewTests.swift index 92256b455..fb2b950b4 100644 --- a/App/Tests/GhosttySurfaceViewTests.swift +++ b/App/Tests/GhosttySurfaceViewTests.swift @@ -1,4 +1,6 @@ import AppKit +import Clocks +import ConcurrencyExtras import Foundation import GhosttyKit import Testing @@ -7,6 +9,58 @@ import Testing @MainActor struct GhosttySurfaceViewTests { + private func makeFocusTestWindow() -> NSWindow { + NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), styleMask: [.titled], backing: .buffered, defer: true) + } + + private func makeFocusTestSurface(runtime: GhosttyRuntime) -> GhosttySurfaceView { + GhosttySurfaceView( + runtime: runtime, workingDirectory: nil, context: GHOSTTY_SURFACE_CONTEXT_TAB, + skipsSurfaceCreationForTesting: true + ) + } + + @Test func newerFocusRequestCancelsAnOlderRequestThatIsStillRetrying() async { + let runtime = GhosttyRuntime() + let clock = TestClock() + let requests = GhosttySurfaceView.FocusRequests() + let window = makeFocusTestWindow() + let stale = makeFocusTestSurface(runtime: runtime) + let latest = makeFocusTestSurface(runtime: runtime) + window.contentView?.addSubview(latest) + + GhosttySurfaceView.moveFocus(to: stale, clock: clock, requests: requests) + await Task.megaYield() + GhosttySurfaceView.moveFocus(to: latest, clock: clock, requests: requests) + await Task.megaYield() + #expect(window.firstResponder === latest) + + window.contentView?.addSubview(stale) + await clock.advance(by: .seconds(2)) + await Task.megaYield() + + #expect(window.firstResponder === latest) + } + + @Test func focusRequestRetriesUntilTheSurfaceHasAWindow() async { + let runtime = GhosttyRuntime() + let clock = TestClock() + let requests = GhosttySurfaceView.FocusRequests() + let window = makeFocusTestWindow() + let surface = makeFocusTestSurface(runtime: runtime) + + GhosttySurfaceView.moveFocus(to: surface, clock: clock, requests: requests) + await Task.megaYield() + #expect(window.firstResponder !== surface) + + window.contentView?.addSubview(surface) + await clock.advance(by: .seconds(2)) + await Task.megaYield() + + #expect(window.firstResponder === surface) + } + @Test func editingAndCompositionAreLocalToEachSurface() { let runtime = GhosttyRuntime() let edited = GhosttySurfaceView( diff --git a/App/Tests/SettingsFilePersistenceTests.swift b/App/Tests/SettingsFilePersistenceTests.swift index da1a2c488..c0f0de30e 100644 --- a/App/Tests/SettingsFilePersistenceTests.swift +++ b/App/Tests/SettingsFilePersistenceTests.swift @@ -60,6 +60,42 @@ struct SettingsFilePersistenceTests { #expect(decoded.ghosttyConfigPath == "/Users/me/.config/prowl/ghostty.conf") } + private func storedGlobalSettings( + removing removedKey: String? = nil, + setting extra: [String: Any] = [:] + ) throws -> GlobalSettings { + let encoded = try JSONEncoder().encode(GlobalSettings.default) + var object = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + if let removedKey { object[removedKey] = nil } + object.merge(extra) { _, new in new } + return try JSONDecoder().decode( + GlobalSettings.self, + from: JSONSerialization.data(withJSONObject: object) + ) + } + + @Test func storedLegacyRestoreLayoutValueMigratesToOn() throws { + let settings = try storedGlobalSettings( + removing: "restoreTerminalLayout", + setting: ["restoreTerminalLayoutOnLaunch": false] + ) + + #expect(settings.restoreTerminalLayoutOnLaunch == true) + } + + @Test func restoreLayoutOffStaysOffAfterMigration() throws { + var settings = GlobalSettings.default + settings.restoreTerminalLayoutOnLaunch = false + + let encoded = try JSONEncoder().encode(settings) + let decoded = try JSONDecoder().decode(GlobalSettings.self, from: encoded) + + #expect(decoded.restoreTerminalLayoutOnLaunch == false) + let object = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + #expect(object["restoreTerminalLayout"] as? Bool == false) + #expect(object["restoreTerminalLayoutOnLaunch"] == nil) + } + @Test(.dependencies) func loadWritesDefaultsWhenMissing() throws { let storage = SettingsTestStorage() @@ -219,7 +255,7 @@ struct SettingsFilePersistenceTests { #expect(settings.global.mergedWorktreeAction == nil) #expect(settings.global.promptForWorktreeCreation == true) #expect(settings.global.defaultWorktreeBaseDirectoryPath == nil) - #expect(settings.global.restoreTerminalLayoutOnLaunch == false) + #expect(settings.global.restoreTerminalLayoutOnLaunch == true) #expect(settings.global.defaultEditorID == OpenWorktreeAction.automaticSettingsID) #expect(settings.global.showRunButtonInToolbar == true) #expect(settings.global.showDefaultEditorInToolbar == true) diff --git a/App/Tests/WorktreeTerminalManagerTests.swift b/App/Tests/WorktreeTerminalManagerTests.swift index 5d1b7bd44..146cd88f6 100644 --- a/App/Tests/WorktreeTerminalManagerTests.swift +++ b/App/Tests/WorktreeTerminalManagerTests.swift @@ -888,16 +888,17 @@ struct WorktreeTerminalManagerTests { #expect(restored.isTitleLocked == false) } - @Test func restoreLayoutSnapshotFailClosedClearsSnapshotWhenWorktreeMissing() async { + @Test func restoreLayoutSnapshotFailClosedClearsSnapshotWhenSnapshotIsInvalid() async { let clearCount = LockIsolated(0) + let worktree = makeWorktree() let snapshot = TerminalLayoutSnapshotPayload( worktrees: [ TerminalLayoutSnapshotPayload.SnapshotWorktree( - worktreeID: "/tmp/repo/wt-1", - selectedTabID: "F96839F5-1371-4841-9E41-49124D918A67", + worktreeID: worktree.id, + selectedTabID: nil, tabs: [ TerminalLayoutSnapshotPayload.SnapshotTab( - tabID: "F96839F5-1371-4841-9E41-49124D918A67", + tabID: "not-a-uuid", title: nil, icon: nil, splitRoot: .leaf(surfaceID: "9B2F6D8C-44A4-42C5-8F9E-962108301901") @@ -919,7 +920,7 @@ struct WorktreeTerminalManagerTests { ) let stream = manager.eventStream() - await manager.restoreLayoutSnapshot(from: []) + await manager.restoreLayoutSnapshot(from: [worktree]) let event = await nextEvent(stream) { event in if case .layoutRestoreFailed = event { @@ -932,7 +933,7 @@ struct WorktreeTerminalManagerTests { #expect(event == .layoutRestoreFailed(message: "Saved terminal layout was invalid and has been reset")) } - @Test func restoreLayoutSnapshotSkipsMissingWorktreesWhenAllowed() async throws { + @Test func restoreLayoutSnapshotSkipsMissingWorktrees() async throws { let clearCount = LockIsolated(0) let available = makeWorktree() let missingID = "/tmp/missing/main" @@ -968,7 +969,7 @@ struct WorktreeTerminalManagerTests { ) let stream = manager.eventStream() - await manager.restoreLayoutSnapshot(from: [available], allowsMissingWorktrees: true) + await manager.restoreLayoutSnapshot(from: [available]) let event = await nextEvent(stream) { event in switch event { diff --git a/docs-ai/014-terminal-layout-persistence/001-action.md b/docs-ai/014-terminal-layout-persistence/001-action.md index 846c8af07..8bfeb83d6 100644 --- a/docs-ai/014-terminal-layout-persistence/001-action.md +++ b/docs-ai/014-terminal-layout-persistence/001-action.md @@ -16,6 +16,7 @@ | 2026-04-08 | Tab title and icon persisted in the snapshot (optional fields, backward-compatible decode) | PR #186 → [022-tab-title-and-icon](../022-tab-title-and-icon/000-plan.md) | | 2026-06-01 | Default View launch race + restoration hang fix | PR #380 — see [002-launch-restore-races.md](002-launch-restore-races.md) | | 2026-06-16 | scenePhase-save-clears-snapshot launch race fix | PR #459 — see [002-launch-restore-races.md](002-launch-restore-races.md) | +| 2026-11-01 | Promoted: default on, also once for existing settings files, no "experimental" label, toggle moved to General. A missing worktree no longer resets the layout; a restore without a selection falls back to the last focused worktree | #573 | ## Outcome & current state (as of 2026-07-12) @@ -73,8 +74,19 @@ persistence"), `docs/components/settings.md` (Advanced pane), and two races surfaced months later (see [002-launch-restore-races.md](002-launch-restore-races.md)). -## Open questions +## Promotion decision (2026-11-01, #573) -- The feature has shipped enabled-by-hand since 2026-04-01 yet is still marked - "(experimental)" with default `false`; no recorded decision either promotes or - retires the experimental label. +Live checks found two blockers for default-on, both fixed in the promotion change: + +- One saved worktree that was removed outside Prowl dropped the whole layout, with an + "invalid" warning. The snapshot itself is valid, so missing worktrees are now always skipped. + Only a structurally invalid snapshot fails closed. +- `repositoriesChanged` clears the selection before restore, and `.layoutRestored(nil)` selected + nothing. A launch with no saved tabs opened with an empty detail view. It now uses the same + fallback as the default view, which also accepts plain folders. + +Existing settings files store `false` for anyone who kept the old default, because every field +is encoded on save. onevcat chose to turn the feature on for them too, and accepted that this +also overrides an explicit off once. The stored key changed from `restoreTerminalLayoutOnLaunch` +to `restoreTerminalLayout`, so no migration state is needed: the old key is ignored, and a later +off stays off. The property keeps its name. diff --git a/docs/components/settings.md b/docs/components/settings.md index 2895b9e2a..14adeb654 100644 --- a/docs/components/settings.md +++ b/docs/components/settings.md @@ -27,14 +27,14 @@ and opens that section's root. | Section | Controls | |-----|----------| -| **General** | Application language (Follow System, 简体中文, English; the same per-app setting as System Settings → Language & Region → Applications; takes effect on next launch), appearance (system/light/dark), terminal config (share Ghostty's config files or use a dedicated file; Open Config, Reload), default app for opening worktrees, diff tool, confirm-before-quit, default view mode, window chrome tint, automatic repository icon detection, toolbar buttons (Run / Open-in-editor), dim unfocused splits. | +| **General** | Application language (Follow System, 简体中文, English; the same per-app setting as System Settings → Language & Region → Applications; takes effect on next launch), appearance (system/light/dark), terminal config (share Ghostty's config files or use a dedicated file; Open Config, Reload), default app for opening worktrees, diff tool, confirm-before-quit, default view mode, restore terminal layout on launch + clear saved layout, window chrome tint, automatic repository icon detection, toolbar buttons (Run / Open-in-editor), dim unfocused splits. | | **Notifications** | In-app alerts, notification sound picker (Never / system sounds / Prowl Classic), macOS system notifications, move-notified-to-top, command-finished notification + threshold, Dock badge & bounce. → [notifications](notifications.md) | | **Shortcuts** | Remap app keyboard shortcuts; view defaults; resolve conflicts. → [keyboard-shortcuts](../reference/keyboard-shortcuts.md) | | **Worktree** | Worktree creation/deletion defaults: prompt on create, fetch before create, base directory, copy ignored/untracked files, automatic local-branch cleanup, merged-worktree action, archived auto-delete period (asks for confirmation when the new period would delete archived worktrees right away). | | **Updates** | Auto-check toggle, "Check for Updates Now". → [updates](updates.md) | | **GitHub** | Enable GitHub integration (uses the `gh` CLI). → [github-pull-requests](github-pull-requests.md) | | **Commands** | Global Custom Commands. Enabled commands appear in the window toolbar; each repo can independently hide a Global command. → [custom-actions](custom-actions.md) | -| **Advanced** | Analytics, crash reports, restore terminal layout on launch (experimental) + clear saved layout. | +| **Advanced** | Analytics, crash reports. | | **Agents → Display** | Active Agents panel auto-show, terminal titles, Shelf status markers, and Agent Island enablement, Monitor, global shortcut, silent opacity, and floating-position reset, and **Detect agents in SSH sessions** (Prowl's own SSH connection; asked at the first `ssh`). → [active-agents](active-agents.md), [agent-island](agent-island.md), [agent-detection](agent-detection.md#remote-agents-over-ssh) | | **Agents → Profiles** | Named launch presets for supported agent runtimes (model, effort, execution mode, tab/split placement, extra arguments, opt-in dedicated home for a separate account) with a live launch preview. List order is the recommendation fallback. → [agent-profiles](agent-profiles.md) | | **Agents → CLI & Skills** | Install/status for the bundled `prowl` CLI, the local socket path it uses to reach the app and whether this app is **listening** on it, and the **Agent Skills** section that links the bundled skills into your agents' skill folders. → [cli](cli.md) | diff --git a/docs/components/terminal.md b/docs/components/terminal.md index 1301a4ce6..226489e11 100644 --- a/docs/components/terminal.md +++ b/docs/components/terminal.md @@ -300,14 +300,24 @@ no in-app Ghostty theme picker; change Ghostty colors via its config file. ## Layout persistence -If `restoreTerminalLayoutOnLaunch` is enabled, Prowl saves the tab/split layout -(which worktrees, tabs, split trees, titles, icons) and restores it on next -launch. It saves when you quit and each time Prowl stops being the active app, so -a crash loses only the changes made since you last switched away. Notification -bodies are not persisted. If a repository fails to load at -launch (for example, its folder is gone), Prowl still restores the layout of the -other repositories and applies the default view mode; the worktrees of the -failed repository are skipped. +`restoreTerminalLayout` is on by default (Settings → General → Terminal +Layout). Prowl saves the tab/split layout (which worktrees, tabs, split trees, +titles, icons, each pane's folder) and restores it on next launch. Restored panes +start a new shell: running commands and agents do not start again, and a folder +outside the worktree falls back to the worktree root. It saves when you quit and +each time Prowl stops being the active app, so a crash loses only the changes made +since you last switched away. Notification bodies are not persisted. + +If a saved worktree is gone at launch (it was removed outside Prowl, or its +repository failed to load), Prowl skips it and restores the rest. The next save +no longer holds the skipped worktree, so its layout does not return if the folder +comes back later. If the saved layout names no available worktree, Prowl selects +the last focused worktree, else the first available worktree or folder. A saved file that +cannot be read is removed without a message. A saved layout that cannot be applied +is reset, and Prowl shows a warning. + +When the default view is Canvas, launch focuses the selected tab of the saved +worktree, so keyboard input goes to the highlighted card. ## Gotchas for agents diff --git a/docs/reference/settings-fields.md b/docs/reference/settings-fields.md index af4aa7f87..08447c974 100644 --- a/docs/reference/settings-fields.md +++ b/docs/reference/settings-fields.md @@ -84,7 +84,7 @@ reads have no scheduled removal date. | `copyIgnoredOnWorktreeCreate` | Bool | `false` | Copy `.gitignore`'d files into new worktrees. | | `copyUntrackedOnWorktreeCreate` | Bool | `false` | Copy untracked files into new worktrees. | | `pullRequestMergeStrategy` | enum (`merge`/`squash`/`rebase`) | `merge` | Default PR merge strategy. | -| `restoreTerminalLayoutOnLaunch` | Bool | `false` | Restore tabs/splits on launch. | +| `restoreTerminalLayout` | Bool | `true` | Restore tabs/splits on launch. Replaces `restoreTerminalLayoutOnLaunch`: Prowl ignores the old field, so the setting turns on once for every existing settings file. | | `terminalFontSize` | Float32? | `nil` | Remembered terminal font size; `nil` follows Ghostty's `font-size`. A size equal to Ghostty's `font-size` is stored as `nil`, also at launch. | | `ghosttyConfigPath` | String? | `nil` | Dedicated Ghostty config file for Prowl; replaces Ghostty's own config files. `nil` = share Ghostty's config. | | `archivedAutoDeletePeriod` | enum? (days) | `nil` | Auto-delete archived worktrees after N days; `nil` = never. |