diff --git a/SupacodeSettingsShared/App/AppShortcuts.swift b/SupacodeSettingsShared/App/AppShortcuts.swift index 0ed4ce5c3..1986b96c6 100644 --- a/SupacodeSettingsShared/App/AppShortcuts.swift +++ b/SupacodeSettingsShared/App/AppShortcuts.swift @@ -5,7 +5,7 @@ import SwiftUI // Compile-time checkable shortcut identifier. public nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRepresentable { - case commandPalette, openSettings, checkForUpdates, showMainWindow + case commandPalette, projectSwitcher, openSettings, checkForUpdates, showMainWindow case toggleLeftSidebar, revealInSidebar case newWorktree, refreshWorktrees, archivedWorktrees, archiveWorktree case deleteWorktree, confirmWorktreeAction @@ -36,6 +36,7 @@ public nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRep private var stableKey: String { switch self { case .commandPalette: "commandPalette" + case .projectSwitcher: "projectSwitcher" case .openSettings: "openSettings" case .checkForUpdates: "checkForUpdates" case .showMainWindow: "showMainWindow" @@ -65,6 +66,7 @@ public nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRep private static let stableKeyMap: [String: AppShortcutID] = [ "commandPalette": .commandPalette, + "projectSwitcher": .projectSwitcher, "openSettings": .openSettings, "checkForUpdates": .checkForUpdates, "showMainWindow": .showMainWindow, @@ -106,6 +108,7 @@ public nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRep public var displayName: String { switch self { case .commandPalette: "Command Palette" + case .projectSwitcher: "Project Switcher" case .openSettings: "Open Settings" case .checkForUpdates: "Check For Updates" case .showMainWindow: "Show Main Window" @@ -286,7 +289,8 @@ public enum AppShortcuts { // MARK: - Shortcut definitions. - public static let commandPalette = AppShortcut(id: .commandPalette, key: "p", modifiers: .command) + public static let commandPalette = AppShortcut(id: .commandPalette, key: "p", modifiers: [.command, .shift]) + public static let projectSwitcher = AppShortcut(id: .projectSwitcher, key: "p", modifiers: .command) public static let openSettings = AppShortcut(id: .openSettings, key: ",", modifiers: .command) public static let checkForUpdates = AppShortcut(id: .checkForUpdates, key: "u", modifiers: .command) public static let showMainWindow = AppShortcut(id: .showMainWindow, key: "0", modifiers: .command) @@ -378,7 +382,7 @@ public enum AppShortcuts { public static let groups: [AppShortcutGroup] = [ AppShortcutGroup( category: .general, - shortcuts: [commandPalette, openSettings, checkForUpdates, showMainWindow] + shortcuts: [projectSwitcher, commandPalette, openSettings, checkForUpdates, showMainWindow] ), AppShortcutGroup(category: .sidebar, shortcuts: [toggleLeftSidebar, revealInSidebar]), AppShortcutGroup( diff --git a/supacode/App/ContentView.swift b/supacode/App/ContentView.swift index 6c81a1834..c6c92b3d2 100644 --- a/supacode/App/ContentView.swift +++ b/supacode/App/ContentView.swift @@ -153,7 +153,8 @@ private struct CommandPaletteOverlayHost: View { #endif return CommandPaletteOverlayView( store: store.scope(state: \.commandPalette, action: \.commandPalette), - items: CommandPaletteFeature.commandPaletteItems( + items: CommandPaletteFeature.items( + in: store.commandPalette.mode, from: repositoriesStore.state, ghosttyCommands: ghosttyShortcuts.commandPaletteEntries, scripts: store.allScripts, diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index af7eb2e20..23499903e 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -420,8 +420,13 @@ struct SupacodeApp: App { TerminalCommands(ghosttyShortcuts: ghosttyShortcuts) WindowCommands(ghosttyShortcuts: ghosttyShortcuts) CommandGroup(after: .textEditing) { + Button("Project Switcher") { + store.send(.commandPalette(.presentInMode(.projectSwitcher))) + } + .appKeyboardShortcut(AppShortcuts.projectSwitcher.effective(from: store.settings.shortcutOverrides)) + .help("Switch between projects, sorted by most recently used") Button("Command Palette") { - store.send(.commandPalette(.togglePresented)) + store.send(.commandPalette(.presentInMode(.commands))) } .appKeyboardShortcut(AppShortcuts.commandPalette.effective(from: store.settings.shortcutOverrides)) .help("Command Palette") diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 858ffb324..ea46241d9 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -893,7 +893,39 @@ struct AppFeature { return .none case .commandPalette(.delegate(.selectWorktree(let worktreeID))): - return .send(.repositories(.selectWorktree(worktreeID))) + // Always-focused-terminal: palette completion lands focus in the + // chosen worktree's terminal, matching the menu/deeplink paths + // that already passed focusTerminal: true. + return .send(.repositories(.selectWorktree(worktreeID, focusTerminal: true))) + + case .commandPalette(.delegate(.selectProject(let repositoryID))): + // Resolve project → last-used worktree, falling back to the + // project's main worktree when the MRU pointer is empty or + // points at a since-removed worktree. The selectWorktree call + // carries focusTerminal: true (the default) so activation + // lands the user in the terminal. + let repositories = state.repositories + let mruWorktree = + repositories.lastWorktreeByProject[repositoryID] + .flatMap { repositories.worktreeExists($0) ? $0 : nil } + let resolved: Worktree.ID? = + mruWorktree + ?? repositories.repositories[id: repositoryID]? + .worktrees.first(where: repositories.isMainWorktree)?.id + ?? repositories.repositories[id: repositoryID]?.worktrees.first?.id + guard let resolved else { return .none } + return .send(.repositories(.selectWorktree(resolved))) + + case .commandPalette(.delegate(.dismissedWithoutSelection)): + // Always-focused-terminal invariant. Cancellation paths (Esc, outside + // tap, programmatic close) don't carry a destination; refocus the + // current worktree's terminal so the cursor never lingers nowhere. + guard let worktreeID = state.repositories.selectedWorktreeID, + state.repositories.sidebarItems[id: worktreeID] != nil + else { return .none } + return .send( + .repositories(.sidebarItems(.element(id: worktreeID, action: .focusTerminalRequested))) + ) case .commandPalette(.delegate(.checkForUpdates)): return .send(.updates(.checkForUpdates)) diff --git a/supacode/Features/CommandPalette/CommandPaletteItem.swift b/supacode/Features/CommandPalette/CommandPaletteItem.swift index b5adb9c9b..1716f4804 100644 --- a/supacode/Features/CommandPalette/CommandPaletteItem.swift +++ b/supacode/Features/CommandPalette/CommandPaletteItem.swift @@ -10,25 +10,34 @@ struct CommandPaletteItem: Identifiable, Equatable { let subtitle: String? let kind: Kind let priorityTier: Int + /// `true` for the project-switcher row that represents the project the + /// user is already in. The switcher still renders it (so you can see + /// where you are), but the overlay skips it for the default selection so + /// Cmd+P then Enter lands on the previous project instead of being a + /// no-op. Always `false` outside the project switcher. + let isCurrentProject: Bool init( id: String, title: String, subtitle: String?, kind: Kind, - priorityTier: Int = defaultPriorityTier + priorityTier: Int = defaultPriorityTier, + isCurrentProject: Bool = false ) { self.id = id self.title = title self.subtitle = subtitle self.kind = kind self.priorityTier = priorityTier + self.isCurrentProject = isCurrentProject } enum Kind: Equatable { case checkForUpdates case openRepository case worktreeSelect(Worktree.ID) + case selectProject(Repository.ID) case openSettings case newWorktree case removeWorktree(Worktree.ID, Repository.ID) @@ -70,6 +79,8 @@ struct CommandPaletteItem: Identifiable, Equatable { true case .worktreeSelect, .removeWorktree, .archiveWorktree: false + case .selectProject: + true case .renameBranch: true case .runScript, .stopScript: @@ -97,6 +108,7 @@ struct CommandPaletteItem: Identifiable, Equatable { .rerunFailedJobs, .openFailingCheckDetails, .worktreeSelect, + .selectProject, .removeWorktree, .archiveWorktree, .renameBranch: @@ -128,6 +140,7 @@ struct CommandPaletteItem: Identifiable, Equatable { .rerunFailedJobs, .openFailingCheckDetails, .worktreeSelect, + .selectProject, .removeWorktree, .archiveWorktree, .renameBranch, diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index e025d5d40..8f2e80545 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -6,9 +6,21 @@ import SupacodeSettingsShared @Reducer struct CommandPaletteFeature { + /// Two narrow surfaces sharing one palette UI. `.commands` is the + /// historical full palette (workttrees, scripts, ghostty actions, PR + /// actions, settings); `.projectSwitcher` shows only repositories, + /// sorted by `RepositoriesFeature.State.projectMRU`. Mode lives in + /// State so the items builder, the view, and the dismiss handler all + /// read the same source of truth. + enum PaletteMode: Equatable, Sendable { + case commands + case projectSwitcher + } + @ObservableState struct State: Equatable { var isPresented = false + var mode: PaletteMode = .commands var query = "" var selectedIndex: Int? var recencyByItemID: [CommandPaletteItem.ID: TimeInterval] = [:] @@ -23,9 +35,14 @@ struct CommandPaletteFeature { case binding(BindingAction) case setPresented(Bool) case togglePresented + /// Open the palette in a specific mode. No-op if already presented in + /// the same mode; switches mode and refreshes selection if already + /// presented in a different mode. Wired to the Cmd+P / Cmd+Shift+P + /// menu items in `supacodeApp.swift`. + case presentInMode(PaletteMode) case activateItem(CommandPaletteItem) - case updateSelection(itemsCount: Int) - case resetSelection(itemsCount: Int) + case updateSelection(itemsCount: Int, defaultIndex: Int) + case resetSelection(itemsCount: Int, defaultIndex: Int) case moveSelection(SelectionMove, itemsCount: Int) case pruneRecency([CommandPaletteItem.ID]) case delegate(Delegate) @@ -34,6 +51,7 @@ struct CommandPaletteFeature { @CasePathable enum Delegate: Equatable { case selectWorktree(Worktree.ID) + case selectProject(Repository.ID) case checkForUpdates case openSettings case newWorktree @@ -54,6 +72,11 @@ struct CommandPaletteFeature { case openFailingCheckDetails(Worktree.ID) case runScript(ScriptDefinition) case stopScript(UUID, name: String) + /// Palette closed without the user activating an item (Esc, outside + /// tap, programmatic dismiss). AppFeature uses this to refocus the + /// current worktree's terminal — the "terminal is the default + /// focus" invariant. + case dismissedWithoutSelection #if DEBUG case debugTestToast(RepositoriesFeature.StatusToast) #endif @@ -69,6 +92,7 @@ struct CommandPaletteFeature { return .none case .setPresented(let isPresented): + let wasPresented = state.isPresented state.isPresented = isPresented if isPresented { loadRecency(into: &state) @@ -76,17 +100,39 @@ struct CommandPaletteFeature { } else { state.query = "" state.selectedIndex = nil + state.mode = .commands + } + if wasPresented, !isPresented { + return .send(.delegate(.dismissedWithoutSelection)) } return .none case .togglePresented: + let wasPresented = state.isPresented state.isPresented.toggle() if state.isPresented { + state.mode = .commands loadRecency(into: &state) state.selectedIndex = nil } else { state.query = "" state.selectedIndex = nil + state.mode = .commands + } + if wasPresented, !state.isPresented { + return .send(.delegate(.dismissedWithoutSelection)) + } + return .none + + case .presentInMode(let mode): + let wasPresented = state.isPresented + let modeChanged = state.mode != mode + state.isPresented = true + state.mode = mode + if !wasPresented || modeChanged { + loadRecency(into: &state) + state.query = "" + state.selectedIndex = nil } return .none @@ -94,11 +140,14 @@ struct CommandPaletteFeature { state.isPresented = false state.query = "" state.selectedIndex = nil + state.mode = .commands state.recencyByItemID[item.id] = now.timeIntervalSince1970 saveRecency(state.recencyByItemID) + // No `.dismissedWithoutSelection` here: every activation delegate + // resolves to a destination that owns its own focus transition. return .send(.delegate(delegateAction(for: item.kind))) - case .updateSelection(let itemsCount): + case .updateSelection(let itemsCount, let defaultIndex): if itemsCount == 0 { state.selectedIndex = nil return .none @@ -106,12 +155,12 @@ struct CommandPaletteFeature { if let selectedIndex = state.selectedIndex, selectedIndex >= itemsCount { state.selectedIndex = itemsCount - 1 } else if state.selectedIndex == nil { - state.selectedIndex = 0 + state.selectedIndex = min(max(defaultIndex, 0), itemsCount - 1) } return .none - case .resetSelection(let itemsCount): - state.selectedIndex = itemsCount == 0 ? nil : 0 + case .resetSelection(let itemsCount, let defaultIndex): + state.selectedIndex = itemsCount == 0 ? nil : min(max(defaultIndex, 0), itemsCount - 1) return .none case .moveSelection(let direction, let itemsCount): @@ -292,6 +341,7 @@ struct CommandPaletteFeature { ) -> [CommandPaletteItem.ID] { var ids = CommandPaletteItemID.globalIDs for repository in repositories { + ids.append(CommandPaletteItemID.project(repository.id)) ids.append(contentsOf: CommandPaletteItemID.pullRequestIDs(repositoryID: repository.id)) for worktree in repository.worktrees { ids.append(CommandPaletteItemID.worktreeSelect(worktree.id)) @@ -304,6 +354,95 @@ struct CommandPaletteFeature { } return ids } + + /// Mode-aware item dispatch. The palette overlay calls this once per + /// re-render; the mode comes from `State.mode` and the per-mode + /// builders own all selection / ranking / subtitle decisions. + static func items( + in mode: PaletteMode, + from repositories: RepositoriesFeature.State, + ghosttyCommands: [GhosttyCommand] = [], + scripts: [ScriptDefinition] = [], + runningScriptIDs: Set = [] + ) -> [CommandPaletteItem] { + switch mode { + case .commands: + return commandPaletteItems( + from: repositories, + ghosttyCommands: ghosttyCommands, + scripts: scripts, + runningScriptIDs: runningScriptIDs + ) + case .projectSwitcher: + return projectSwitcherItems(from: repositories) + } + } + + /// Project switcher items. Order: `projectMRU` head first (the current + /// project), then prior MRU entries in recency order, then any loaded + /// repository not yet in MRU sorted by display name. `priorityTier` + /// carries the ordinal so `prioritizeItems` keeps MRU order with an + /// empty query; the fuzzy scorer takes over once the user types. + /// + /// The current project (`projectMRU` head) is rendered so you can see + /// where you are, but it carries `isCurrentProject` so the overlay skips + /// it for the default selection — Cmd+P then Enter lands on the previous + /// project (Cmd+Tab style) instead of being a no-op. Intra-project + /// navigation lives on the worktree palette (Cmd+Shift+P). + static func projectSwitcherItems( + from repositories: RepositoriesFeature.State + ) -> [CommandPaletteItem] { + let allRepos = repositories.repositories + let mruOrder = repositories.projectMRU + let currentProjectID = mruOrder.first + let mruSet = Set(mruOrder) + let mruRepos: [Repository] = mruOrder.compactMap { allRepos[id: $0] } + let remaining = allRepos + .filter { !mruSet.contains($0.id) } + .sorted { lhs, rhs in + let lhsName = projectDisplayName(for: lhs, in: repositories) + let rhsName = projectDisplayName(for: rhs, in: repositories) + return lhsName.localizedCaseInsensitiveCompare(rhsName) == .orderedAscending + } + let ordered: [Repository] = mruRepos + Array(remaining) + + return ordered.enumerated().map { index, repo in + let title = projectDisplayName(for: repo, in: repositories) + let subtitle = projectSubtitle(for: repo, in: repositories) + return CommandPaletteItem( + id: CommandPaletteItemID.project(repo.id), + title: title, + subtitle: subtitle, + kind: .selectProject(repo.id), + priorityTier: index, + isCurrentProject: repo.id == currentProjectID + ) + } + } + + private static func projectDisplayName( + for repository: Repository, + in state: RepositoriesFeature.State + ) -> String { + let custom = state.sidebar.sections[repository.id]?.title + return Repository.sidebarDisplayName(custom: custom, fallback: repository.name) + } + + /// Subtitle for the project row: name of the last-used worktree if the + /// user has been in this project before, otherwise the path. Hides the + /// path noise once MRU is established without leaving cold-start rows + /// subtitleless. + private static func projectSubtitle( + for repository: Repository, + in state: RepositoriesFeature.State + ) -> String? { + if let worktreeID = state.lastWorktreeByProject[repository.id], + let row = state.sidebarItems[id: worktreeID] + { + return row.name.isEmpty ? nil : row.name + } + return repository.rootURL.path(percentEncoded: false) + } } private func pullRequestItems( @@ -475,6 +614,7 @@ private func makeClosePullRequestItem( private enum CommandPaletteItemID { static let ghosttyPrefix = "ghostty." + static let projectPrefix = "project." static let globalCheckForUpdates = "global.check-for-updates" static let globalOpenSettings = "global.open-settings" static let globalOpenRepository = "global.open-repository" @@ -482,6 +622,10 @@ private enum CommandPaletteItemID { static let globalRefreshWorktrees = "global.refresh-worktrees" static let globalViewArchivedWorktrees = "global.view-archived-worktrees" + static func project(_ repositoryID: Repository.ID) -> CommandPaletteItem.ID { + "\(projectPrefix)\(repositoryID).select" + } + static var globalIDs: [CommandPaletteItem.ID] { [ globalCheckForUpdates, @@ -595,6 +739,8 @@ private func delegateAction(for kind: CommandPaletteItem.Kind) -> CommandPalette switch kind { case .worktreeSelect(let id): return .selectWorktree(id) + case .selectProject(let repositoryID): + return .selectProject(repositoryID) case .checkForUpdates: return .checkForUpdates case .openSettings: @@ -656,6 +802,7 @@ private func pullRequestDelegateAction( case .openFailingCheckDetails(let worktreeID): return .openFailingCheckDetails(worktreeID) case .worktreeSelect, + .selectProject, .checkForUpdates, .openSettings, .newWorktree, diff --git a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift index 912ba7399..6c4f10060 100644 --- a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift +++ b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift @@ -95,11 +95,24 @@ struct CommandPaletteOverlayView: View { } private func updateSelection(rows: [CommandPaletteItem]) { - store.send(.updateSelection(itemsCount: rows.count)) + store.send(.updateSelection(itemsCount: rows.count, defaultIndex: defaultSelectionIndex(rows: rows))) } private func resetSelection(rows: [CommandPaletteItem]) { - store.send(.resetSelection(itemsCount: rows.count)) + store.send(.resetSelection(itemsCount: rows.count, defaultIndex: defaultSelectionIndex(rows: rows))) + } + + /// Where the cursor lands when there's no prior selection. Normally the + /// top row, but the project switcher skips its own current-project row + /// (rendered at index 0 with an empty query) so Cmd+P then Enter switches + /// to the previous project instead of being a no-op. Once the user types, + /// the fuzzy-ranked top match wins again. + private func defaultSelectionIndex(rows: [CommandPaletteItem]) -> Int { + let trimmedQuery = store.query.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmedQuery.isEmpty, rows.count > 1, rows.first?.isCurrentProject == true else { + return 0 + } + return 1 } private func moveSelection(_ direction: MoveCommandDirection, rows: [CommandPaletteItem]) { @@ -341,7 +354,7 @@ private struct CommandPaletteRowView: View { .ghosttyCommand, .openPullRequest, .markPullRequestReady, .mergePullRequest, .closePullRequest, .copyFailingJobURL, .copyCiFailureLogs, - .rerunFailedJobs, .openFailingCheckDetails, .worktreeSelect: + .rerunFailedJobs, .openFailingCheckDetails, .worktreeSelect, .selectProject: return nil case .removeWorktree: return "Remove" @@ -394,6 +407,8 @@ private struct CommandPaletteRowView: View { return "exclamationmark.triangle" case .worktreeSelect: return nil + case .selectProject: + return "folder" case .removeWorktree: return "trash" case .archiveWorktree: @@ -420,7 +435,7 @@ private struct CommandPaletteRowView: View { .copyCiFailureLogs, .rerunFailedJobs, .openFailingCheckDetails: return true - case .worktreeSelect, .removeWorktree, .archiveWorktree: + case .worktreeSelect, .selectProject, .removeWorktree, .archiveWorktree: return false case .renameBranch: return true @@ -506,6 +521,8 @@ private struct CommandPaletteRowView: View { switch row.kind { case .worktreeSelect: base = "Switch to \(row.title)" + case .selectProject: + base = "Switch to project \(row.title)" case .checkForUpdates: base = "Check for Updates" case .openRepository: diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 3d0cdde01..e7f8f5d33 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -142,6 +142,16 @@ struct RepositoriesFeature { /// system-driven cleanup promotions. var worktreeHistoryBackStack: [Worktree.ID] = [] var worktreeHistoryForwardStack: [Worktree.ID] = [] + /// Per-project MRU stack — most-recent-first, deduped. Updated whenever + /// `setSingleWorktreeSelection` lands on a concrete worktree; the + /// containing repository moves to the head. Drives the Cmd+P project + /// switcher sort. Session-only; not persisted. + var projectMRU: [Repository.ID] = [] + /// Last-selected worktree per repository, captured on every concrete + /// selection. Cmd+P `selectProject` resolves a project pick to the + /// specific worktree the user last had open in it, instead of dumping + /// them at the repo's "main". + var lastWorktreeByProject: [Repository.ID: Worktree.ID] = [:] /// Single source of truth for all user-curated sidebar state — /// section order / collapse / pin / unpin / archive / focused /// worktree — persisted to `~/.supacode/sidebar.json`. Replaces @@ -4962,6 +4972,21 @@ extension RepositoriesFeature.State { if recordHistory { recordWorktreeHistoryTransition(from: previousID, to: worktreeID) } + if let worktreeID, let repositoryID = repositoryID(containing: worktreeID) { + recordProjectMRU(repositoryID: repositoryID, worktreeID: worktreeID) + } + } + + /// Hoists the containing project to the head of `projectMRU` and pins the + /// selected worktree as the project's last-used. Called from every concrete + /// `setSingleWorktreeSelection` — selection-clearing (nil) paths are no-ops + /// so a "select nothing" transition can't poison the MRU head. + mutating func recordProjectMRU(repositoryID: Repository.ID, worktreeID: Worktree.ID) { + if let existingIndex = projectMRU.firstIndex(of: repositoryID) { + projectMRU.remove(at: existingIndex) + } + projectMRU.insert(repositoryID, at: 0) + lastWorktreeByProject[repositoryID] = worktreeID } /// Records a fresh worktree navigation: pushes the previous selection onto @@ -5055,6 +5080,13 @@ extension RepositoriesFeature.State { selection = nextSelectedWorktreeID.map(SidebarSelection.worktree) sidebarSelectedWorktreeIDs = nextSidebarSelectedWorktreeIDs recordWorktreeHistoryTransition(from: previousSelection, to: nextSelectedWorktreeID) + // Sidebar selection bypasses `setSingleWorktreeSelection`, so record the + // project MRU here too — otherwise clicking a worktree (the dominant nav + // path) never updates `lastWorktreeByProject`, and Cmd+P `selectProject` + // can't return you to the worktree you actually had open. + if let nextSelectedWorktreeID, let repositoryID = repositoryID(containing: nextSelectedWorktreeID) { + recordProjectMRU(repositoryID: repositoryID, worktreeID: nextSelectedWorktreeID) + } var effects: [Effect] = [] if focusTerminal, let nextSelectedWorktreeID, diff --git a/supacode/Features/Repositories/Views/ToolbarStatusView.swift b/supacode/Features/Repositories/Views/ToolbarStatusView.swift index 432e1772b..a85d70c41 100644 --- a/supacode/Features/Repositories/Views/ToolbarStatusView.swift +++ b/supacode/Features/Repositories/Views/ToolbarStatusView.swift @@ -50,7 +50,7 @@ private struct MotivationalStatusView: View { .foregroundStyle(style.color) .font(.callout) .accessibilityHidden(true) - Text("\(context.date, format: .dateTime.hour().minute()) – Open Command Palette (⌘P)") + Text("\(context.date, format: .dateTime.hour().minute()) – Open Command Palette (⌘⇧P)") .font(.footnote) .monospaced() .foregroundStyle(.secondary) diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 015524983..c1949b4d9 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -811,7 +811,7 @@ private struct ToolbarPlaceholderContent: ToolbarContent { HStack(spacing: 8) { Image(systemName: "sun.max.fill") .font(.callout) - Text("00:00 – Open Command Palette (⌘P)") + Text("00:00 – Open Command Palette (⌘⇧P)") .font(.footnote) .monospaced() } diff --git a/supacodeTests/CommandPaletteFeatureTests.swift b/supacodeTests/CommandPaletteFeatureTests.swift index ec7b44d7b..ce4213867 100644 --- a/supacodeTests/CommandPaletteFeatureTests.swift +++ b/supacodeTests/CommandPaletteFeatureTests.swift @@ -1062,6 +1062,55 @@ struct CommandPaletteFeatureTests { await store.receive(.delegate(.ghosttyCommand("goto_split:right"))) } + @Test func updateSelection_usesDefaultIndexWhenNoSelection() async { + let store = TestStore(initialState: CommandPaletteFeature.State()) { + CommandPaletteFeature() + } + + // The project switcher hands a defaultIndex of 1 to skip its own + // current-project row; with no prior selection the cursor lands there. + await store.send(.updateSelection(itemsCount: 3, defaultIndex: 1)) { + $0.selectedIndex = 1 + } + } + + @Test func updateSelection_clampsDefaultIndexToLastRow() async { + let store = TestStore(initialState: CommandPaletteFeature.State()) { + CommandPaletteFeature() + } + + // A defaultIndex past the end (e.g. a stale switcher list shrank to one + // row) clamps to the last valid index rather than overrunning. + await store.send(.updateSelection(itemsCount: 1, defaultIndex: 1)) { + $0.selectedIndex = 0 + } + } + + @Test func updateSelection_keepsExistingSelectionOverDefault() async { + var state = CommandPaletteFeature.State() + state.selectedIndex = 0 + let store = TestStore(initialState: state) { + CommandPaletteFeature() + } + + // An in-bounds existing selection wins; defaultIndex only seeds a nil + // selection so arrow-key navigation isn't yanked back on every refresh. + await store.send(.updateSelection(itemsCount: 3, defaultIndex: 1)) + } + + @Test func resetSelection_usesDefaultIndex() async { + var state = CommandPaletteFeature.State() + state.selectedIndex = 2 + let store = TestStore(initialState: state) { + CommandPaletteFeature() + } + + // resetSelection (fired on query change) snaps straight to defaultIndex. + await store.send(.resetSelection(itemsCount: 3, defaultIndex: 1)) { + $0.selectedIndex = 1 + } + } + // MARK: - Script items. @Test func commandPaletteItems_includesRunItemsForConfiguredScripts() { @@ -1149,6 +1198,189 @@ struct CommandPaletteFeatureTests { #expect(ids.contains("script.\(definition.id).run")) #expect(ids.contains("script.\(definition.id).stop")) } + + @Test func projectSwitcherItems_emptyWhenNoRepositories() { + let items = CommandPaletteFeature.projectSwitcherItems(from: RepositoriesFeature.State()) + #expect(items.isEmpty) + } + + @Test func projectSwitcherItems_sortsByMRUThenAlphabeticalAndMarksCurrent() { + let wtA = makeWorktree(id: "/tmp/repo-a/wt", name: "wt", repoRoot: "/tmp/repo-a") + let wtB = makeWorktree(id: "/tmp/repo-b/wt", name: "wt", repoRoot: "/tmp/repo-b") + let wtC = makeWorktree(id: "/tmp/repo-c/wt", name: "wt", repoRoot: "/tmp/repo-c") + let repoA = makeRepository(rootPath: "/tmp/repo-a", name: "Alpha", worktrees: [wtA]) + let repoB = makeRepository(rootPath: "/tmp/repo-b", name: "Bravo", worktrees: [wtB]) + let repoC = makeRepository(rootPath: "/tmp/repo-c", name: "Charlie", worktrees: [wtC]) + var state = RepositoriesFeature.State(reconciledRepositories: [repoA, repoB, repoC]) + + // MRU = [A, C] after select C then select A. The current project (A, + // the MRU head) is rendered first but flagged isCurrentProject so the + // overlay skips it for the default selection. + state.setSingleWorktreeSelection(wtC.id) + state.setSingleWorktreeSelection(wtA.id) + + let items = CommandPaletteFeature.projectSwitcherItems(from: state) + + // MRU head (A), then prior MRU (C), then non-MRU sorted alphabetically (B). + #expect(items.map(\.id) == [ + "project.\(repoA.id).select", + "project.\(repoC.id).select", + "project.\(repoB.id).select", + ]) + // priorityTier mirrors visible order so an empty-query prioritizeItems() + // pass preserves the MRU ranking even though item-level recency is empty. + #expect(items.map(\.priorityTier) == [0, 1, 2]) + // Only the current project (A) carries the skip-for-default flag. + #expect(items.map(\.isCurrentProject) == [true, false, false]) + } + + @Test func projectSwitcherItems_showsOnlyCurrentProjectFlagged() { + let wt = makeWorktree(id: "/tmp/only/wt", name: "wt", repoRoot: "/tmp/only") + let repo = makeRepository(rootPath: "/tmp/only", name: "Only", worktrees: [wt]) + var state = RepositoriesFeature.State(reconciledRepositories: [repo]) + + state.setSingleWorktreeSelection(wt.id) + + let items = CommandPaletteFeature.projectSwitcherItems(from: state) + // The only project is the current one — still rendered (so you see + // where you are), flagged current. The overlay's defaultIndex guard + // keeps it selected since there's nowhere else to go. + #expect(items.map(\.id) == ["project.\(repo.id).select"]) + #expect(items.map(\.isCurrentProject) == [true]) + } + + @Test func projectSwitcherItems_subtitleShowsLastWorktreeWhenKnown() { + let wtMain = makeWorktree(id: "/tmp/repo/main", name: "main", repoRoot: "/tmp/repo") + let wtFeat = makeWorktree(id: "/tmp/repo/feat", name: "feature/x", repoRoot: "/tmp/repo") + let wtOther = makeWorktree(id: "/tmp/other/wt", name: "wt", repoRoot: "/tmp/other") + let repo = makeRepository(rootPath: "/tmp/repo", name: "Repo", worktrees: [wtMain, wtFeat]) + let other = makeRepository(rootPath: "/tmp/other", name: "Other", worktrees: [wtOther]) + var state = RepositoriesFeature.State(reconciledRepositories: [repo, other]) + + // Land on `repo` first so it records a lastWorktree, then move to + // `other` so `repo` becomes the previous project (the switcher entry). + state.setSingleWorktreeSelection(wtFeat.id) + state.setSingleWorktreeSelection(wtOther.id) + + let items = CommandPaletteFeature.projectSwitcherItems(from: state) + let item = items.first { $0.id == "project.\(repo.id).select" } + #expect(item?.subtitle == "feature/x") + } + + @Test func projectSwitcherItems_subtitleFallsBackToPathWhenNoMRU() { + let wt = makeWorktree(id: "/tmp/never-opened/main", name: "main", repoRoot: "/tmp/never-opened") + let repo = makeRepository(rootPath: "/tmp/never-opened", name: "Repo", worktrees: [wt]) + let state = RepositoriesFeature.State(reconciledRepositories: [repo]) + + let items = CommandPaletteFeature.projectSwitcherItems(from: state) + let item = items.first { $0.id == "project.\(repo.id).select" } + #expect(item?.subtitle == "/tmp/never-opened") + } + + @Test func items_dispatchByMode() { + let wt = makeWorktree(id: "/tmp/repo/main", name: "main", repoRoot: "/tmp/repo") + let wtOther = makeWorktree(id: "/tmp/other/wt", name: "wt", repoRoot: "/tmp/other") + let repo = makeRepository(rootPath: "/tmp/repo", name: "Repo", worktrees: [wt]) + let other = makeRepository(rootPath: "/tmp/other", name: "Other", worktrees: [wtOther]) + var state = RepositoriesFeature.State(reconciledRepositories: [repo, other]) + // Other is current; Repo is the previous project the switcher surfaces. + state.setSingleWorktreeSelection(wt.id) + state.setSingleWorktreeSelection(wtOther.id) + + let commands = CommandPaletteFeature.items(in: .commands, from: state) + let projects = CommandPaletteFeature.items(in: .projectSwitcher, from: state) + + // .commands surfaces the existing palette items (globals, worktree + // selects, etc.) and excludes project items entirely. + #expect(commands.contains { $0.id.hasPrefix("global.") }) + #expect(commands.contains { $0.id == "worktree.\(wt.id).select" }) + #expect(commands.allSatisfy { !$0.id.hasPrefix("project.") }) + + // .projectSwitcher is project-only. The current project (Other) leads, + // flagged so the overlay skips it for the default selection; the prior + // project (Repo) follows. + #expect(projects == [ + CommandPaletteItem( + id: "project.\(other.id).select", + title: "Other", + subtitle: "wt", + kind: .selectProject(other.id), + priorityTier: 0, + isCurrentProject: true + ), + CommandPaletteItem( + id: "project.\(repo.id).select", + title: "Repo", + subtitle: "main", + kind: .selectProject(repo.id), + priorityTier: 1 + ), + ]) + } + + @Test func presentInMode_setsModeAndPresentsTheView() async { + let store = TestStore(initialState: CommandPaletteFeature.State()) { + CommandPaletteFeature() + } + + await store.send(.presentInMode(.projectSwitcher)) { + $0.isPresented = true + $0.mode = .projectSwitcher + } + } + + @Test func setPresented_falseFromPresentedEmitsDismissedDelegate() async { + let store = TestStore( + initialState: CommandPaletteFeature.State(isPresented: true, mode: .projectSwitcher) + ) { + CommandPaletteFeature() + } + + await store.send(.setPresented(false)) { + $0.isPresented = false + $0.mode = .commands + } + await store.receive(\.delegate.dismissedWithoutSelection) + } + + @Test func setPresented_falseFromNotPresentedDoesNotEmitDelegate() async { + let store = TestStore(initialState: CommandPaletteFeature.State()) { + CommandPaletteFeature() + } + await store.send(.setPresented(false)) + } + + @Test func togglePresented_dismissEmitsDelegate() async { + let store = TestStore(initialState: CommandPaletteFeature.State(isPresented: true)) { + CommandPaletteFeature() + } + + await store.send(.togglePresented) { + $0.isPresented = false + } + await store.receive(\.delegate.dismissedWithoutSelection) + } + + @Test func activateItem_doesNotEmitDismissedDelegate() async { + let store = TestStore(initialState: CommandPaletteFeature.State(isPresented: true)) { + CommandPaletteFeature() + } withDependencies: { + $0.date.now = Date(timeIntervalSince1970: 0) + } + + let item = CommandPaletteItem( + id: "global.open-settings", + title: "Open Settings", + subtitle: nil, + kind: .openSettings + ) + await store.send(.activateItem(item)) { + $0.isPresented = false + $0.recencyByItemID[item.id] = 0 + } + // The activation delegate carries the focus — no dismissal echo. + await store.receive(\.delegate.openSettings) + } } private func makeWorktree( diff --git a/supacodeTests/RepositoriesFeatureProjectMRUTests.swift b/supacodeTests/RepositoriesFeatureProjectMRUTests.swift new file mode 100644 index 000000000..5377e5720 --- /dev/null +++ b/supacodeTests/RepositoriesFeatureProjectMRUTests.swift @@ -0,0 +1,129 @@ +import ComposableArchitecture +import Foundation +import IdentifiedCollections +import SupacodeSettingsShared +import Testing + +@testable import supacode + +@MainActor +struct RepositoriesFeatureProjectMRUTests { + @Test func setSingleWorktreeSelection_pushesContainingProjectOntoMRU() { + let worktree = makeWorktree(id: "/tmp/repo-a/wt", name: "wt", repoRoot: "/tmp/repo-a") + let repository = makeRepository(rootPath: "/tmp/repo-a", name: "A", worktrees: [worktree]) + var state = RepositoriesFeature.State(reconciledRepositories: [repository]) + + state.setSingleWorktreeSelection(worktree.id) + + #expect(state.projectMRU == [repository.id]) + #expect(state.lastWorktreeByProject[repository.id] == worktree.id) + } + + @Test func setSingleWorktreeSelection_dedupesProjectInMRUOnRepeat() { + let wt1 = makeWorktree(id: "/tmp/repo-a/wt-1", name: "wt-1", repoRoot: "/tmp/repo-a") + let wt2 = makeWorktree(id: "/tmp/repo-a/wt-2", name: "wt-2", repoRoot: "/tmp/repo-a") + let repo = makeRepository(rootPath: "/tmp/repo-a", name: "A", worktrees: [wt1, wt2]) + var state = RepositoriesFeature.State(reconciledRepositories: [repo]) + + state.setSingleWorktreeSelection(wt1.id) + state.setSingleWorktreeSelection(wt2.id) + + #expect(state.projectMRU == [repo.id]) + #expect(state.lastWorktreeByProject[repo.id] == wt2.id) + } + + @Test func setSingleWorktreeSelection_movesPriorProjectBehindLatest() { + let wtA = makeWorktree(id: "/tmp/repo-a/wt", name: "wt", repoRoot: "/tmp/repo-a") + let wtB = makeWorktree(id: "/tmp/repo-b/wt", name: "wt", repoRoot: "/tmp/repo-b") + let repoA = makeRepository(rootPath: "/tmp/repo-a", name: "A", worktrees: [wtA]) + let repoB = makeRepository(rootPath: "/tmp/repo-b", name: "B", worktrees: [wtB]) + var state = RepositoriesFeature.State(reconciledRepositories: [repoA, repoB]) + + state.setSingleWorktreeSelection(wtA.id) + state.setSingleWorktreeSelection(wtB.id) + state.setSingleWorktreeSelection(wtA.id) + + #expect(state.projectMRU == [repoA.id, repoB.id]) + #expect(state.lastWorktreeByProject[repoA.id] == wtA.id) + #expect(state.lastWorktreeByProject[repoB.id] == wtB.id) + } + + @Test func setSingleWorktreeSelection_nilDoesNotPolluteMRU() { + let wtA = makeWorktree(id: "/tmp/repo-a/wt", name: "wt", repoRoot: "/tmp/repo-a") + let repoA = makeRepository(rootPath: "/tmp/repo-a", name: "A", worktrees: [wtA]) + var state = RepositoriesFeature.State(reconciledRepositories: [repoA]) + + state.setSingleWorktreeSelection(wtA.id) + state.setSingleWorktreeSelection(nil) + + // Clearing the selection must leave the MRU head pointing at the + // project the user just stepped out of, not at "nothing." Otherwise + // a programmatic deselect (e.g. archive flow) would wipe the very + // recency signal Cmd+P relies on. + #expect(state.projectMRU == [repoA.id]) + #expect(state.lastWorktreeByProject[repoA.id] == wtA.id) + } + + @Test func sidebarSelectionChange_recordsProjectMRU() { + // The dominant interaction — clicking a worktree in the sidebar — routes + // through `.selectionChanged` / `reduceSelectionChangedEffect`, NOT + // `setSingleWorktreeSelection`. If MRU recording only lives in the latter, + // sidebar navigation never updates `lastWorktreeByProject`, and Cmd+P + // `selectProject` always falls back to the first/main worktree instead of + // the one the user actually had open. This pins that bug. + let wt1 = makeWorktree(id: "/tmp/repo-a/wt-1", name: "wt-1", repoRoot: "/tmp/repo-a") + let wt2 = makeWorktree(id: "/tmp/repo-a/wt-2", name: "wt-2", repoRoot: "/tmp/repo-a") + let repoA = makeRepository(rootPath: "/tmp/repo-a", name: "A", worktrees: [wt1, wt2]) + var state = RepositoriesFeature.State(reconciledRepositories: [repoA]) + + // Simulate a sidebar click landing on the *second* worktree. + _ = state.reduceSelectionChangedEffect(selections: [.worktree(wt2.id)], focusTerminal: false) + + #expect(state.projectMRU == [repoA.id]) + #expect(state.lastWorktreeByProject[repoA.id] == wt2.id) + } + + @Test func sidebarSelectionChange_movesPriorProjectBehindLatest() { + let wtA = makeWorktree(id: "/tmp/repo-a/wt", name: "wt", repoRoot: "/tmp/repo-a") + let wtB = makeWorktree(id: "/tmp/repo-b/wt", name: "wt", repoRoot: "/tmp/repo-b") + let repoA = makeRepository(rootPath: "/tmp/repo-a", name: "A", worktrees: [wtA]) + let repoB = makeRepository(rootPath: "/tmp/repo-b", name: "B", worktrees: [wtB]) + var state = RepositoriesFeature.State(reconciledRepositories: [repoA, repoB]) + + _ = state.reduceSelectionChangedEffect(selections: [.worktree(wtA.id)], focusTerminal: false) + _ = state.reduceSelectionChangedEffect(selections: [.worktree(wtB.id)], focusTerminal: false) + _ = state.reduceSelectionChangedEffect(selections: [.worktree(wtA.id)], focusTerminal: false) + + #expect(state.projectMRU == [repoA.id, repoB.id]) + #expect(state.lastWorktreeByProject[repoA.id] == wtA.id) + #expect(state.lastWorktreeByProject[repoB.id] == wtB.id) + } +} + +private func makeWorktree( + id: String, + name: String, + repoRoot: String +) -> Worktree { + Worktree( + id: id, + name: name, + detail: "detail", + workingDirectory: URL(fileURLWithPath: id), + repositoryRootURL: URL(fileURLWithPath: repoRoot) + ) +} + +private func makeRepository( + rootPath: String, + name: String, + worktrees: [Worktree] +) -> Repository { + let rootURL = URL(fileURLWithPath: rootPath) + return Repository( + id: rootURL.path(percentEncoded: false), + rootURL: rootURL, + name: name, + worktrees: IdentifiedArray(uniqueElements: worktrees) + ) +}