diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 6b151dde..402f61fd 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -1259,6 +1259,9 @@ struct AppFeature { let shouldEnterShelf = settingsFile.global.defaultViewMode == .shelf && !state.repositories.isShelfActive + let shouldEnterCanvas = + settingsFile.global.defaultViewMode == .canvas + && !state.repositories.isShowingCanvas var effects: [Effect] = [] if let selectedWorktreeID { // Plain folders use .repository selection, not .worktree @@ -1273,6 +1276,11 @@ struct AppFeature { if shouldEnterShelf { effects.append(.send(.repositories(.toggleShelf))) } + // Enter Canvas after the selection effects so `.selectCanvas` + // records the just-selected worktree as the pre-Canvas anchor. + if shouldEnterCanvas { + effects.append(.send(.repositories(.toggleCanvas))) + } return effects.isEmpty ? .none : .merge(effects) case .terminalEvent(.layoutRestoreFailed(let message)): diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 818aaa43..eef95c8a 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -495,11 +495,18 @@ struct RepositoriesFeature { // Restore clears the selection we just set, and any books not // in the saved layout would linger as stray spines. @Shared(.settingsFile) var settingsFile - if settingsFile.global.defaultViewMode == .shelf, - state.launchRestoreMode != .restoreLayout, - !state.isShelfActive - { - allEffects.append(.send(.toggleShelf)) + if state.launchRestoreMode != .restoreLayout { + switch settingsFile.global.defaultViewMode { + case .shelf where !state.isShelfActive: + allEffects.append(.send(.toggleShelf)) + case .canvas where !state.isShowingCanvas: + // `.toggleCanvas` shares Shelf's guards: it only enters + // when at least one worktree row exists, otherwise it + // falls back to Normal. + allEffects.append(.send(.toggleCanvas)) + case .normal, .shelf, .canvas: + break + } } return .merge(allEffects) diff --git a/supacode/Features/Settings/Models/DefaultViewMode.swift b/supacode/Features/Settings/Models/DefaultViewMode.swift index fa7c02a6..f0800198 100644 --- a/supacode/Features/Settings/Models/DefaultViewMode.swift +++ b/supacode/Features/Settings/Models/DefaultViewMode.swift @@ -1,10 +1,12 @@ /// Which presentation the app enters on launch. `normal` keeps the /// historical behavior (sidebar + terminal detail); `shelf` boots -/// straight into Shelf so power users who live in Shelf don't have to -/// toggle it every time they open Prowl. +/// straight into Shelf and `canvas` boots straight into Canvas, so +/// power users who live in those views don't have to toggle them +/// every time they open Prowl. enum DefaultViewMode: String, CaseIterable, Identifiable, Codable, Sendable { case normal case shelf + case canvas var id: String { rawValue } @@ -14,6 +16,8 @@ enum DefaultViewMode: String, CaseIterable, Identifiable, Codable, Sendable { return "Normal View" case .shelf: return "Shelf View" + case .canvas: + return "Canvas View" } } } diff --git a/supacode/Features/Settings/Views/AppearanceSettingsView.swift b/supacode/Features/Settings/Views/AppearanceSettingsView.swift index 59780295..1470e04d 100644 --- a/supacode/Features/Settings/Views/AppearanceSettingsView.swift +++ b/supacode/Features/Settings/Views/AppearanceSettingsView.swift @@ -90,7 +90,7 @@ struct AppearanceSettingsView: View { Text(mode.title).tag(mode) } } - .help("View Prowl starts in on launch. Shelf requires at least one worktree or folder.") + .help("View Prowl starts in on launch. Shelf and Canvas require at least one worktree or folder.") } Section("Default Editor") { Picker( diff --git a/supacodeTests/ShelfFeatureTests.swift b/supacodeTests/ShelfFeatureTests.swift index 2a942fc6..a801298e 100644 --- a/supacodeTests/ShelfFeatureTests.swift +++ b/supacodeTests/ShelfFeatureTests.swift @@ -930,6 +930,122 @@ struct ShelfFeatureTests { await store.finish() } + @Test(.dependencies) func defaultViewCanvasPreferenceDispatchesToggleAfterSnapshot() async { + let repoRoot = "/tmp/default-canvas-repo" + let rootURL = URL(fileURLWithPath: repoRoot) + let worktree = Worktree( + id: "\(repoRoot)/main", + name: "main", + detail: "", + workingDirectory: URL(fileURLWithPath: "\(repoRoot)/main"), + repositoryRootURL: rootURL + ) + let repository = Repository( + id: repoRoot, + rootURL: rootURL, + name: "repo", + worktrees: IdentifiedArray(uniqueElements: [worktree]) + ) + + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { + var updated = $0.global + updated.defaultViewMode = .canvas + $0.global = updated + } + // Restore settings after the test so `@Shared` state doesn't leak + // across parallel test runs in the same process. + defer { + $settingsFile.withLock { + var updated = $0.global + updated.defaultViewMode = .normal + $0.global = updated + } + } + + var initialState = RepositoriesFeature.State() + initialState.lastFocusedWorktreeID = worktree.id + initialState.shouldRestoreLastFocusedWorktree = true + initialState.snapshotPersistencePhase = .restoring + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } + + await store.send(.repositorySnapshotLoaded([repository])) { + $0.repositories = [repository] + $0.repositoryRoots = [rootURL] + $0.selection = .worktree(worktree.id) + $0.shouldRestoreLastFocusedWorktree = false + $0.isInitialLoadComplete = true + } + await store.receive(\.delegate.repositoriesChanged) + await store.receive(\.delegate.selectedWorktreeChanged) + // `.toggleCanvas` enters Canvas via `.selectCanvas`, which records the + // current worktree as the pre-Canvas anchor before flipping selection. + await store.receive(\.toggleCanvas) + await store.receive(\.selectCanvas) { + $0.preCanvasWorktreeID = worktree.id + $0.preCanvasTerminalTargetID = worktree.id + $0.selection = .canvas + } + await store.finish() + } + + @Test(.dependencies) func defaultViewCanvasDefersDuringLayoutRestore() async { + // Mirrors the Shelf deferral: during Layout Restore the snapshot-load + // hook stays quiet and the AppFeature `.layoutRestored` path takes over. + let repoRoot = "/tmp/default-canvas-restore-repo" + let rootURL = URL(fileURLWithPath: repoRoot) + let worktree = Worktree( + id: "\(repoRoot)/main", + name: "main", + detail: "", + workingDirectory: URL(fileURLWithPath: "\(repoRoot)/main"), + repositoryRootURL: rootURL + ) + let repository = Repository( + id: repoRoot, + rootURL: rootURL, + name: "repo", + worktrees: IdentifiedArray(uniqueElements: [worktree]) + ) + + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { + var updated = $0.global + updated.defaultViewMode = .canvas + $0.global = updated + } + defer { + $settingsFile.withLock { + var updated = $0.global + updated.defaultViewMode = .normal + $0.global = updated + } + } + + var initialState = RepositoriesFeature.State() + initialState.lastFocusedWorktreeID = worktree.id + initialState.shouldRestoreLastFocusedWorktree = true + initialState.snapshotPersistencePhase = .restoring + initialState.launchRestoreMode = .restoreLayout + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } + + await store.send(.repositorySnapshotLoaded([repository])) { + $0.repositories = [repository] + $0.repositoryRoots = [rootURL] + $0.selection = .worktree(worktree.id) + $0.shouldRestoreLastFocusedWorktree = false + $0.isInitialLoadComplete = true + } + await store.receive(\.delegate.repositoriesChanged) + await store.receive(\.delegate.selectedWorktreeChanged) + // No `.toggleCanvas` here — the Layout Restore path is responsible. + await store.finish() + } + @Test func isShowingShelfRequiresAtLeastOneRepository() { let rootURL = URL(fileURLWithPath: "/tmp/repo") let repository = Repository(