Skip to content
Merged
10 changes: 7 additions & 3 deletions SupacodeSettingsShared/App/AppShortcuts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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, worktreeSwitcher, openSettings, checkForUpdates, showMainWindow
case toggleLeftSidebar, revealInSidebar
case expandAllSidebarGroups, collapseAllSidebarGroups
case newWorktree, refreshWorktrees, archivedWorktrees, archiveWorktree
Expand Down Expand Up @@ -39,6 +39,7 @@ public nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRep
private var stableKey: String {
switch self {
case .commandPalette: "commandPalette"
case .worktreeSwitcher: "worktreeSwitcher"
case .openSettings: "openSettings"
case .checkForUpdates: "checkForUpdates"
case .showMainWindow: "showMainWindow"
Expand Down Expand Up @@ -75,6 +76,7 @@ public nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRep

private static let stableKeyMap: [String: AppShortcutID] = [
"commandPalette": .commandPalette,
"worktreeSwitcher": .worktreeSwitcher,
"openSettings": .openSettings,
"checkForUpdates": .checkForUpdates,
"showMainWindow": .showMainWindow,
Expand Down Expand Up @@ -128,6 +130,7 @@ public nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRep
public var displayName: String {
switch self {
case .commandPalette: "Command Palette"
case .worktreeSwitcher: "Go to Worktree"
case .openSettings: "Open Settings"
case .checkForUpdates: "Check For Updates"
case .showMainWindow: "Show Main Window"
Expand Down Expand Up @@ -318,7 +321,8 @@ public struct AppShortcutGroup: Identifiable {
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 worktreeSwitcher = AppShortcut(id: .worktreeSwitcher, 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, .shift])
Expand Down Expand Up @@ -444,7 +448,7 @@ public enum AppShortcuts {
public static let groups: [AppShortcutGroup] = [
AppShortcutGroup(
category: .general,
shortcuts: [commandPalette, openSettings, checkForUpdates, showMainWindow]
shortcuts: [worktreeSwitcher, commandPalette, openSettings, checkForUpdates, showMainWindow]
),
AppShortcutGroup(
category: .sidebar,
Expand Down
3 changes: 2 additions & 1 deletion supacode/App/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,8 @@ private struct CommandPaletteOverlayHost: View {
let paletteStore = store.scope(state: \.commandPalette, action: \.commandPalette)
return CommandPalettePanelHost(
store: paletteStore,
items: CommandPaletteFeature.commandPaletteItems(
items: CommandPaletteFeature.items(
in: paletteStore.mode,
from: repositoriesStore.state,
ghosttyCommands: ghosttyShortcuts.commandPaletteEntries,
scripts: store.allScripts,
Expand Down
7 changes: 6 additions & 1 deletion supacode/App/supacodeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -505,8 +505,13 @@ struct SupacodeApp: App {
}
WindowCommands(ghosttyShortcuts: ghosttyShortcuts)
CommandGroup(after: .textEditing) {
Button("Go to Worktree") {
store.send(.commandPalette(.presentInMode(.worktreeSwitcher)))
}
.appKeyboardShortcut(AppShortcuts.worktreeSwitcher.effective(from: store.settings.shortcutOverrides))
.help("Switch between worktrees, 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")
Expand Down
6 changes: 6 additions & 0 deletions supacode/Domain/RepositoryIdentity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,18 @@ nonisolated struct RepositoryID: Hashable, Sendable, Codable, CustomStringConver
/// Same string-backed shape as `RepositoryID`; the two are compiler-distinct so
/// a repo id and a worktree id can't be confused at a call site.
nonisolated struct WorktreeID: Hashable, Sendable, Codable, CustomStringConvertible {
/// Prefix for the synthetic id a worktree carries while it is being created.
static let pendingPrefix = "pending:"

let rawValue: String

init(_ rawValue: String) { self.rawValue = rawValue }

var description: String { rawValue }

/// A placeholder id for an in-flight creation, not yet a real worktree.
var isPending: Bool { rawValue.hasPrefix(Self.pendingPrefix) }

init(from decoder: any Decoder) throws {
self.rawValue = try decoder.singleValueContainer().decode(String.self)
}
Expand Down
20 changes: 18 additions & 2 deletions supacode/Features/App/Reducer/AppFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1140,7 +1140,21 @@ 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(.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))
Expand Down Expand Up @@ -1282,9 +1296,11 @@ struct AppFeature {
if state.commandPalette.isPresented {
return .send(.commandPalette(.setPresented(false)))
}
// Ghostty's toggle action opens the command palette specifically, so
// force `.commands`; otherwise it would inherit the last-used mode.
return .merge(
.send(.repositories(.selectWorktree(worktreeID))),
.send(.commandPalette(.setPresented(true)))
.send(.commandPalette(.presentInMode(.commands)))
)
case .terminalEvent(.setupScriptConsumed(let worktreeID)):
return .send(.repositories(.consumeSetupScript(worktreeID)))
Expand Down
42 changes: 41 additions & 1 deletion supacode/Features/CommandPalette/CommandPaletteItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,59 @@ struct CommandPaletteItem: Identifiable, Equatable {
let subtitle: String?
let kind: Kind
let priorityTier: Int
/// `true` for the worktree-switcher row that represents the worktree 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
/// ⌘P then Enter lands on the previous worktree instead of being a
/// no-op. Always `false` outside the worktree switcher.
let isCurrentWorktree: Bool
/// Presentation for a worktree-switcher row: text tints, leading icon, and
/// the remote host, mirroring the sidebar. `nil` for every non-switcher item.
let worktreeStyle: WorktreeRowStyle?

/// Tints, leading icon, and the remote host for a worktree-switcher row.
/// Colors are applied to the title / subtitle text directly, matching the
/// sidebar.
struct WorktreeRowStyle: Equatable {
/// Tints the title (the worktree name, or a folder row's own name).
var titleTint: RepositoryColor?
/// Tints the repo-name subtitle. `nil` for folder rows, which have no subtitle.
var repoTint: RepositoryColor?
/// SSH authority for a remote row; drives a `wifi` badge. `nil` when local.
var hostInfo: String?
/// Leading glyph, mirroring the sidebar row's icon. Every switcher row has one.
var icon: WorktreeRowIcon
}

/// Leading glyph for a worktree-switcher row, mirroring the sidebar. The
/// switcher lists only idle rows, so the sidebar's lifecycle variants collapse
/// to these three outcomes.
enum WorktreeRowIcon: Equatable {
/// Git worktree: a template asset (`git-branch` or a pull-request variant),
/// optionally badged with the pull request's aggregate check state.
case pullRequest(SidebarPullRequestIcon, checkBadge: SidebarCheckBadgeState?)
/// A folder row.
case folder
/// A worktree whose working directory is missing.
case missing
}

init(
id: String,
title: String,
subtitle: String?,
kind: Kind,
priorityTier: Int = defaultPriorityTier
priorityTier: Int = defaultPriorityTier,
isCurrentWorktree: Bool = false,
worktreeStyle: WorktreeRowStyle? = nil
) {
self.id = id
self.title = title
self.subtitle = subtitle
self.kind = kind
self.priorityTier = priorityTier
self.isCurrentWorktree = isCurrentWorktree
self.worktreeStyle = worktreeStyle
}

enum Kind: Equatable {
Expand Down
Loading
Loading