diff --git a/docs/components/repositories-and-worktrees.md b/docs/components/repositories-and-worktrees.md index 9d1f60a1..97ad77cd 100644 --- a/docs/components/repositories-and-worktrees.md +++ b/docs/components/repositories-and-worktrees.md @@ -134,16 +134,26 @@ it from Prowl (closing its open terminals); it does **not** delete files on disk ## Opening a worktree in another app -`⌘O` opens the worktree with the auto-detected default app — your first installed -editor (Cursor → Zed → VS Code → Windsurf → …), falling through to Xcode and then -**Finder only when no preferred app is found**. Use the **Open** dropdown in the -worktree's detail toolbar to pick a different app, or set a per-repo default -(`openActionID`) / global default (`defaultEditorID`). Prowl detects: Finder, -Terminal, `$EDITOR`, VS Code (+ Insiders), VSCodium, Cursor, Zed, Windsurf, -Antigravity, Xcode, Android Studio, JetBrains IDEs, GitHub Desktop / Fork / -GitKraken / Sourcetree / Sublime Merge / SmartGit / GitUp, and terminals -(Alacritty, Ghostty, Kitty, Warp, WezTerm). If the chosen app isn't installed, -Prowl shows an alert. +`⌘O` opens the worktree with the selected open action. When the action is +**Automatic** (the default), Prowl inspects the worktree's top-level files and +prefers an app matching the project type: `.xcodeproj`/`.xcworkspace`/ +`Package.swift`/`Project.swift` → Xcode, Gradle files → Android Studio (then +IntelliJ IDEA), `*.sln`/`*.csproj` → Rider, `pom.xml` → IntelliJ IDEA, +`go.mod` → GoLand, `Cargo.toml` → RustRover, `CMakeLists.txt` → CLion, +`composer.json` → PhpStorm, `Gemfile` → RubyMine, Python manifests → PyCharm, +`package.json` → WebStorm. If the matching app isn't installed (or no project +type is detected), it falls back to the generic priority — your first +installed editor (Cursor → Zed → VS Code → Windsurf → …), falling through to +Xcode and then **Finder only when no preferred app is found**. Use the +**Open** dropdown in the worktree's detail toolbar to pick a different app +(this pins it for the repo), or set a per-repo default (`openActionID`) / +global default (`defaultEditorID`). Prowl detects: Finder, Terminal, +`$EDITOR`, VS Code (+ Insiders), VSCodium, Cursor, Zed, Windsurf, Antigravity, +Sublime Text, Xcode, Android Studio, JetBrains IDEs (IntelliJ IDEA, WebStorm, +PyCharm, RustRover, Rider, GoLand, CLion, PhpStorm, RubyMine), GitHub Desktop +/ Fork / Tower / GitKraken / Sourcetree / Sublime Merge / SmartGit / GitUp, +and terminals (Alacritty, Ghostty, iTerm2, Kitty, Warp, WezTerm). If the +chosen app isn't installed, Prowl shows an alert. Other per-row context-menu items: **Copy Path**, **Reveal in Finder**. (Repo Settings lives on the repository **header** menu, not the worktree row.) diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index 5c52ed55..82fb6091 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -19,7 +19,7 @@ Symbols: **⌘** Command · **⇧** Shift · **⌥** Option · **⌃** Control | Action | Default | Command ID | Remappable | |--------|---------|------------|------------| | New Worktree | ⌘N | `new_worktree` | yes | -| Open Worktree (opens in Finder by default) | ⌘O | `open_worktree` | yes | +| Open Worktree (with the selected Open-in app) | ⌘O | `open_worktree` | yes | | Open Repository… | ⌘⇧O | `open_repository` | yes | | Open on Code Host (e.g. GitHub) | ⌘⌃G | `open_pull_request` | yes | | Refresh Worktrees | ⌘⇧R | `refresh_worktrees` | yes | diff --git a/docs/reference/settings-fields.md b/docs/reference/settings-fields.md index 34b0125a..98669ace 100644 --- a/docs/reference/settings-fields.md +++ b/docs/reference/settings-fields.md @@ -24,7 +24,7 @@ JSON is pretty-printed with sorted keys. Legacy `~/.supacode` is migrated to | Field | Type | Default | Effect | |-------|------|---------|--------| | `appearanceMode` | enum (`system`/`light`/`dark`) | `dark` | App appearance. | -| `defaultEditorID` | String | `auto` | Default app to open worktrees (overridable per repo). | +| `defaultEditorID` | String | `auto` | Default app to open worktrees (overridable per repo); `auto` prefers an app matching the detected project type. | | `confirmBeforeQuit` | Bool | `true` | Confirm before quitting Prowl. | | `updateChannel` | enum (`stable`/`tip`) | `stable` | Sparkle release channel. | | `updatesAutomaticallyCheckForUpdates` | Bool | `true` | Background update checks. | @@ -74,7 +74,7 @@ Stored at `~/.prowl/repo//prowl.json` (schema v2). For the tri-state | `setupScript` | String | `""` | Script run automatically after a worktree is created. | | `archiveScript` | String | `""` | Script run automatically before a worktree is archived. | | `runScript` | String | `""` | The on-demand Run Script (`⌘R`). | -| `openActionID` | String | `auto` | App to open this repo's worktrees (overrides `defaultEditorID`). | +| `openActionID` | String | `auto` | App to open this repo's worktrees (overrides `defaultEditorID`); `auto` prefers an app matching the detected project type. | | `worktreeBaseRef` | String? | `nil` | Default base branch/ref for new worktrees. | | `worktreeBaseDirectoryPath` | String? | `nil` | Parent directory for new worktrees (overrides global). | | `copyIgnoredOnWorktreeCreate` | Bool? | `nil` | Copy ignored files; `nil` = use global. | diff --git a/supacode/Domain/OpenWorktreeAction.swift b/supacode/Domain/OpenWorktreeAction.swift index 9316e754..bac70a38 100644 --- a/supacode/Domain/OpenWorktreeAction.swift +++ b/supacode/Domain/OpenWorktreeAction.swift @@ -9,6 +9,7 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { case alacritty case androidStudio case antigravity + case clion case editor case finder case cursor @@ -17,14 +18,21 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { case gitkraken case gitup case ghostty + case goland case intellij + case iterm2 case kitty + case phpstorm case pycharm + case rider + case rubymine case rustrover case smartgit case sourcetree case sublimeMerge + case sublimeText case terminal + case tower case vscode case vscodeInsiders case vscodium @@ -44,19 +52,27 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { case .alacritty: "Alacritty" case .androidStudio: "Android Studio" case .antigravity: "Antigravity" + case .clion: "CLion" case .cursor: "Cursor" case .githubDesktop: "GitHub Desktop" case .gitkraken: "GitKraken" case .gitup: "GitUp" case .ghostty: "Ghostty" + case .goland: "GoLand" case .intellij: "IntelliJ IDEA" + case .iterm2: "iTerm2" case .kitty: "Kitty" + case .phpstorm: "PhpStorm" case .pycharm: "PyCharm" + case .rider: "Rider" + case .rubymine: "RubyMine" case .rustrover: "RustRover" case .smartgit: "SmartGit" case .sourcetree: "Sourcetree" case .sublimeMerge: "Sublime Merge" + case .sublimeText: "Sublime Text" case .terminal: "Terminal" + case .tower: "Tower" case .vscode: "VS Code" case .vscodeInsiders: "VS Code Insiders" case .vscodium: "VSCodium" @@ -74,33 +90,59 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { switch self { case .finder: "Finder" case .editor: "$EDITOR" - case .alacritty, .androidStudio, .antigravity, .cursor, .fork, .githubDesktop, .gitkraken, .gitup, - .ghostty, .intellij, .kitty, .pycharm, .rustrover, .smartgit, .sourcetree, .sublimeMerge, - .terminal, .vscode, .vscodeInsiders, .vscodium, .warp, .webstorm, .wezterm, .windsurf, - .xcode, .zed: + case .alacritty, .androidStudio, .antigravity, .clion, .cursor, .fork, .githubDesktop, .gitkraken, + .gitup, .ghostty, .goland, .intellij, .iterm2, .kitty, .phpstorm, .pycharm, .rider, .rubymine, + .rustrover, .smartgit, .sourcetree, .sublimeMerge, .sublimeText, .terminal, .tower, .vscode, + .vscodeInsiders, .vscodium, .warp, .webstorm, .wezterm, .windsurf, .xcode, .zed: title } } + // Pre-rendered at display size and cached: `icon(forFile:)` plus the + // rasterizing resize cost milliseconds, and toolbar redraws request these + // icons constantly. Only hits are cached so a newly installed app shows up + // without invalidation; lookup misses are microseconds. + private static let menuIconSize = CGSize(width: 16, height: 16) + private static var menuIconCache: [String: MenuIcon] = [:] + var menuIcon: MenuIcon? { switch self { case .editor: return .symbol("apple.terminal") default: + if let cached = Self.menuIconCache[bundleIdentifier] { + return cached + } guard let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) else { return nil } - return .app(NSWorkspace.shared.icon(forFile: appURL.path)) + let icon = Self.resizedIcon(NSWorkspace.shared.icon(forFile: appURL.path), size: Self.menuIconSize) + let menuIcon = MenuIcon.app(icon) + Self.menuIconCache[bundleIdentifier] = menuIcon + return menuIcon } } + private static func resizedIcon(_ image: NSImage, size: CGSize) -> NSImage { + let newImage = NSImage(size: size) + newImage.lockFocus() + image.draw( + in: NSRect(origin: .zero, size: size), + from: NSRect(origin: .zero, size: image.size), + operation: .sourceOver, + fraction: 1.0 + ) + newImage.unlockFocus() + return newImage + } + var isInstalled: Bool { switch self { case .finder, .editor: return true - case .alacritty, .androidStudio, .antigravity, .cursor, .fork, .githubDesktop, .gitkraken, .gitup, - .ghostty, .intellij, .kitty, .pycharm, .rustrover, .smartgit, .sourcetree, .sublimeMerge, - .terminal, .vscode, .vscodeInsiders, .vscodium, .warp, .webstorm, .wezterm, .windsurf, - .xcode, .zed: + case .alacritty, .androidStudio, .antigravity, .clion, .cursor, .fork, .githubDesktop, .gitkraken, + .gitup, .ghostty, .goland, .intellij, .iterm2, .kitty, .phpstorm, .pycharm, .rider, .rubymine, + .rustrover, .smartgit, .sourcetree, .sublimeMerge, .sublimeText, .terminal, .tower, .vscode, + .vscodeInsiders, .vscodium, .warp, .webstorm, .wezterm, .windsurf, .xcode, .zed: return NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) != nil } } @@ -112,20 +154,28 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { case .alacritty: "alacritty" case .androidStudio: "android-studio" case .antigravity: "antigravity" + case .clion: "clion" case .cursor: "cursor" case .fork: "fork" case .githubDesktop: "github-desktop" case .gitkraken: "gitkraken" case .gitup: "gitup" case .ghostty: "ghostty" + case .goland: "goland" case .intellij: "intellij" + case .iterm2: "iterm2" case .kitty: "kitty" + case .phpstorm: "phpstorm" case .pycharm: "pycharm" + case .rider: "rider" + case .rubymine: "rubymine" case .rustrover: "rustrover" case .smartgit: "smartgit" case .sourcetree: "sourcetree" case .sublimeMerge: "sublime-merge" + case .sublimeText: "sublime-text" case .terminal: "terminal" + case .tower: "tower" case .vscode: "vscode" case .vscodeInsiders: "vscode-insiders" case .vscodium: "vscodium" @@ -145,20 +195,28 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { case .alacritty: "org.alacritty" case .androidStudio: "com.google.android.studio" case .antigravity: "com.google.antigravity" + case .clion: "com.jetbrains.CLion" case .cursor: "com.todesktop.230313mzl4w4u92" case .fork: "com.DanPristupov.Fork" case .githubDesktop: "com.github.GitHubClient" case .gitkraken: "com.axosoft.gitkraken" case .gitup: "co.gitup.mac" case .ghostty: "com.mitchellh.ghostty" + case .goland: "com.jetbrains.goland" case .intellij: "com.jetbrains.intellij" + case .iterm2: "com.googlecode.iterm2" case .kitty: "net.kovidgoyal.kitty" + case .phpstorm: "com.jetbrains.PhpStorm" case .pycharm: "com.jetbrains.pycharm" + case .rider: "com.jetbrains.rider" + case .rubymine: "com.jetbrains.rubymine" case .rustrover: "com.jetbrains.rustrover" case .smartgit: "com.syntevo.smartgit" case .sourcetree: "com.torusknot.SourceTreeNotMAS" case .sublimeMerge: "com.sublimemerge" + case .sublimeText: "com.sublimetext.4" case .terminal: "com.apple.Terminal" + case .tower: "com.fournova.Tower3" case .vscode: "com.microsoft.VSCode" case .vscodeInsiders: "com.microsoft.VSCodeInsiders" case .vscodium: "com.vscodium" @@ -180,11 +238,17 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { .windsurf, .vscodeInsiders, .vscodium, + .sublimeText, .androidStudio, .intellij, .webstorm, .pycharm, .rustrover, + .rider, + .goland, + .clion, + .phpstorm, + .rubymine, .antigravity, ] static let terminalPriority: [OpenWorktreeAction] = [ @@ -193,12 +257,14 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { .alacritty, .kitty, .warp, + .iterm2, .terminal, ] static let gitClientPriority: [OpenWorktreeAction] = [ .githubDesktop, .sourcetree, .fork, + .tower, .gitkraken, .sublimeMerge, .smartgit, @@ -223,7 +289,8 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { static func fromSettingsID( _ settingsID: String?, - defaultEditorID: String? + defaultEditorID: String?, + workingDirectory: URL? = nil ) -> OpenWorktreeAction { if let settingsID, settingsID != automaticSettingsID, let action = allCases.first(where: { $0.settingsID == settingsID }) @@ -236,7 +303,7 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { { return action } - return preferredDefault() + return preferredDefault(for: workingDirectory) } static var availableCases: [OpenWorktreeAction] { @@ -248,7 +315,21 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { } static func preferredDefault() -> OpenWorktreeAction { - defaultPriority.first(where: \.isInstalled) ?? .finder + preferredDefault(for: nil) + } + + /// Resolves the automatic open action. When a working directory is given, + /// apps suited to the detected project kind (e.g. Xcode for Swift packages, + /// Android Studio for Gradle projects) are tried before the generic priority. + static func preferredDefault( + for workingDirectory: URL?, + isInstalled: (OpenWorktreeAction) -> Bool = { $0.isInstalled } + ) -> OpenWorktreeAction { + let projectActions = + workingDirectory + .flatMap { WorktreeProjectKind.detect(at: $0) }? + .preferredActions ?? [] + return (projectActions + defaultPriority).first(where: isInstalled) ?? .finder } func perform(with worktree: Worktree, onError: @escaping @MainActor @Sendable (OpenActionError) -> Void) { @@ -259,7 +340,8 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { case .finder: NSWorkspace.shared.activateFileViewerSelecting([worktree.workingDirectory]) // Apps that require CLI arguments instead of Apple Events to open directories. - case .androidStudio, .intellij, .webstorm, .pycharm, .rustrover: + case .androidStudio, .clion, .goland, .intellij, .phpstorm, .pycharm, .rider, .rubymine, + .rustrover, .webstorm: guard let appURL = NSWorkspace.shared.urlForApplication( withBundleIdentifier: bundleIdentifier @@ -291,8 +373,8 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { } } case .alacritty, .antigravity, .cursor, .fork, .githubDesktop, .gitkraken, .gitup, .ghostty, - .kitty, .smartgit, .sourcetree, .sublimeMerge, .terminal, .vscode, .vscodeInsiders, - .vscodium, .warp, .wezterm, .windsurf, .xcode, .zed: + .iterm2, .kitty, .smartgit, .sourcetree, .sublimeMerge, .sublimeText, .terminal, .tower, + .vscode, .vscodeInsiders, .vscodium, .warp, .wezterm, .windsurf, .xcode, .zed: guard let appURL = NSWorkspace.shared.urlForApplication( withBundleIdentifier: bundleIdentifier diff --git a/supacode/Domain/WorktreeProjectKind.swift b/supacode/Domain/WorktreeProjectKind.swift new file mode 100644 index 00000000..be54f6af --- /dev/null +++ b/supacode/Domain/WorktreeProjectKind.swift @@ -0,0 +1,90 @@ +import Foundation + +/// Project ecosystems Prowl can recognize from a worktree's top-level files, +/// used to pick a fitting app when the open action is set to Automatic. +enum WorktreeProjectKind: CaseIterable { + case apple + case android + case dotnet + case java + case golang + case rust + case cpp + case php + case ruby + case python + case web + + /// Detects the project kind from a single shallow listing of `directory`. + /// Checks run from the most specific marker to the least: `package.json` is + /// last because nearly any repo can carry one for tooling, while an + /// `.xcodeproj` or Gradle script identifies the project unambiguously. + static func detect(at directory: URL, fileManager: FileManager = .default) -> WorktreeProjectKind? { + guard let entries = try? fileManager.contentsOfDirectory(atPath: directory.path) else { + return nil + } + let names = Set(entries.map { $0.lowercased() }) + func hasFile(withExtension ext: String) -> Bool { + names.contains { $0.hasSuffix(".\(ext)") } + } + if hasFile(withExtension: "xcodeproj") || hasFile(withExtension: "xcworkspace") + || names.contains("package.swift") || names.contains("project.swift") + { + return .apple + } + if names.contains("settings.gradle") || names.contains("settings.gradle.kts") + || names.contains("build.gradle") || names.contains("build.gradle.kts") + || names.contains("gradlew") + { + return .android + } + if hasFile(withExtension: "sln") || hasFile(withExtension: "csproj") { + return .dotnet + } + if names.contains("pom.xml") { + return .java + } + if names.contains("go.mod") { + return .golang + } + if names.contains("cargo.toml") { + return .rust + } + if names.contains("cmakelists.txt") { + return .cpp + } + if names.contains("composer.json") { + return .php + } + if names.contains("gemfile") { + return .ruby + } + if names.contains("pyproject.toml") || names.contains("setup.py") + || names.contains("requirements.txt") || names.contains("pipfile") + { + return .python + } + if names.contains("package.json") { + return .web + } + return nil + } + + /// Apps to try before `OpenWorktreeAction.defaultPriority` when resolving + /// the Automatic open action for this project kind. + var preferredActions: [OpenWorktreeAction] { + switch self { + case .apple: [.xcode] + case .android: [.androidStudio, .intellij] + case .dotnet: [.rider] + case .java: [.intellij] + case .golang: [.goland] + case .rust: [.rustrover] + case .cpp: [.clion] + case .php: [.phpstorm] + case .ruby: [.rubymine] + case .python: [.pycharm] + case .web: [.webstorm] + } + } +} diff --git a/supacode/Features/App/Reducer/AppFeature+Support.swift b/supacode/Features/App/Reducer/AppFeature+Support.swift index 1a64a789..9f145d30 100644 --- a/supacode/Features/App/Reducer/AppFeature+Support.swift +++ b/supacode/Features/App/Reducer/AppFeature+Support.swift @@ -152,14 +152,19 @@ extension AppFeature { /// Applies a worktree's repository settings (open action, run script) into /// state. Shared by the normal `worktreeSettingsLoaded` action and the Canvas /// focus path so both stay in sync. - func applyWorktreeSettings(_ settings: RepositorySettings, into state: inout State) { + func applyWorktreeSettings( + _ settings: RepositorySettings, + workingDirectory: URL?, + into state: inout State + ) { @Shared(.settingsFile) var settingsFile let normalizedDefaultEditorID = OpenWorktreeAction.normalizedDefaultEditorID( settingsFile.global.defaultEditorID ) state.openActionSelection = OpenWorktreeAction.fromSettingsID( settings.openActionID, - defaultEditorID: normalizedDefaultEditorID + defaultEditorID: normalizedDefaultEditorID, + workingDirectory: workingDirectory ) state.selectedRunScript = settings.runScript } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 725d31c0..d42f665a 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -419,7 +419,8 @@ struct AppFeature { @Shared(.repositorySettings(rootURL)) var repositorySettings state.openActionSelection = OpenWorktreeAction.fromSettingsID( repositorySettings.openActionID, - defaultEditorID: settings.defaultEditorID + defaultEditorID: settings.defaultEditorID, + workingDirectory: selectedWorktree.workingDirectory ) } state.resolvedKeybindings = resolvedKeybindings( @@ -746,7 +747,11 @@ struct AppFeature { // Run + Custom Command items must update in the same transaction — // otherwise the command list lands a frame later and the toolbar // visibly reflows when switching between cards with different commands. - applyWorktreeSettings(repositorySettings, into: &state) + applyWorktreeSettings( + repositorySettings, + workingDirectory: worktree.workingDirectory, + into: &state + ) return applyWorktreeUserSettings(userRepositorySettings, into: &state) case .runScriptDraftChanged(let script): @@ -868,10 +873,16 @@ struct AppFeature { ) case .worktreeSettingsLoaded(let settings, let worktreeID): - guard actionTargetWorktree(repositories: state.repositories)?.id == worktreeID else { + guard let worktree = actionTargetWorktree(repositories: state.repositories), + worktree.id == worktreeID + else { return .none } - applyWorktreeSettings(settings, into: &state) + applyWorktreeSettings( + settings, + workingDirectory: worktree.workingDirectory, + into: &state + ) return .none case .worktreeUserSettingsLoaded(let settings, let worktreeID): diff --git a/supacode/Features/Repositories/Views/OpenWorktreeActionMenuLabelView.swift b/supacode/Features/Repositories/Views/OpenWorktreeActionMenuLabelView.swift index 1778698a..3929c212 100644 --- a/supacode/Features/Repositories/Views/OpenWorktreeActionMenuLabelView.swift +++ b/supacode/Features/Repositories/Views/OpenWorktreeActionMenuLabelView.swift @@ -5,25 +5,12 @@ struct OpenWorktreeActionMenuLabelView: View { let action: OpenWorktreeAction let shortcutHint: String? - private func resizedIcon(_ image: NSImage, size: CGSize) -> NSImage { - let newImage = NSImage(size: size) - newImage.lockFocus() - image.draw( - in: NSRect(origin: .zero, size: size), - from: NSRect(origin: .zero, size: image.size), - operation: .sourceOver, - fraction: 1.0 - ) - newImage.unlockFocus() - return newImage - } - var body: some View { HStack(spacing: 6) { if let icon = action.menuIcon { switch icon { case .app(let image): - Image(nsImage: resizedIcon(image, size: CGSize(width: 16, height: 16))) + Image(nsImage: image) .renderingMode(.original) .accessibilityHidden(true) case .symbol(let name): diff --git a/supacode/Features/Settings/Views/AppearanceSettingsView.swift b/supacode/Features/Settings/Views/AppearanceSettingsView.swift index f429128a..d49d127b 100644 --- a/supacode/Features/Settings/Views/AppearanceSettingsView.swift +++ b/supacode/Features/Settings/Views/AppearanceSettingsView.swift @@ -132,7 +132,10 @@ struct AppearanceSettingsView: View { .tag(action.settingsID) } } - .help("Applies to worktrees without repository overrides.") + .help( + "Applies to worktrees without repository overrides. " + + "Automatic prefers an app matching the project type, e.g. Xcode for Swift projects." + ) } Section("Run") { Toggle( diff --git a/supacodeTests/AppFeatureDefaultEditorTests.swift b/supacodeTests/AppFeatureDefaultEditorTests.swift index a8e9d9bd..d312c749 100644 --- a/supacodeTests/AppFeatureDefaultEditorTests.swift +++ b/supacodeTests/AppFeatureDefaultEditorTests.swift @@ -1,3 +1,4 @@ +import AppKit import ComposableArchitecture import DependenciesTestSupport import Foundation @@ -7,6 +8,9 @@ import Testing @MainActor struct AppFeatureDefaultEditorTests { + nonisolated private static var xcodeInstalled: Bool { + NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.dt.Xcode") != nil + } @Test(.dependencies) func defaultEditorAppliesToAutomaticRepositorySettings() async { let worktree = makeWorktree() let repositoriesState = makeRepositoriesState(worktree: worktree) @@ -95,6 +99,41 @@ struct AppFeatureDefaultEditorTests { await store.finish() } + @Test(.dependencies, .enabled(if: xcodeInstalled)) + func automaticSelectionPrefersXcodeForSwiftPackageWorktree() async throws { + let worktree = makeWorktree() + let fileManager = FileManager.default + try fileManager.createDirectory( + at: worktree.workingDirectory, + withIntermediateDirectories: true + ) + defer { try? fileManager.removeItem(at: worktree.repositoryRootURL) } + try Data().write(to: worktree.workingDirectory.appending(path: "Package.swift")) + let repositoriesState = makeRepositoriesState(worktree: worktree) + let storage = SettingsTestStorage() + let settingsFileURL = URL( + fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json" + ) + let store = TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } withDependencies: { + $0.settingsFileStorage = storage.storage + $0.settingsFileURL = settingsFileURL + } + + await store.send(.repositories(.delegate(.selectedWorktreeChanged(worktree)))) + await store.receive(\.worktreeSettingsLoaded) { + $0.openActionSelection = .xcode + } + await store.receive(\.worktreeUserSettingsLoaded) + await store.finish() + } + @Test(.dependencies) func selectedWorktreeChangedOnlyUpdatesWatcherSelection() async { let worktree = makeWorktree() let repositoriesState = makeRepositoriesState(worktree: worktree) diff --git a/supacodeTests/OpenWorktreeActionTests.swift b/supacodeTests/OpenWorktreeActionTests.swift index e3cfd1bc..b664b7b5 100644 --- a/supacodeTests/OpenWorktreeActionTests.swift +++ b/supacodeTests/OpenWorktreeActionTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import supacode @@ -16,12 +17,28 @@ struct OpenWorktreeActionTests { #expect(settingsIDs.contains("pycharm")) } + @Test func menuOrderIncludesAllCases() { + #expect(Set(OpenWorktreeAction.menuOrder) == Set(OpenWorktreeAction.allCases)) + #expect(OpenWorktreeAction.menuOrder.count == OpenWorktreeAction.allCases.count) + } + @Test func jetBrainsIDEsHaveCorrectBundleIdentifiers() { #expect(OpenWorktreeAction.androidStudio.bundleIdentifier == "com.google.android.studio") #expect(OpenWorktreeAction.intellij.bundleIdentifier == "com.jetbrains.intellij") #expect(OpenWorktreeAction.webstorm.bundleIdentifier == "com.jetbrains.WebStorm") #expect(OpenWorktreeAction.pycharm.bundleIdentifier == "com.jetbrains.pycharm") #expect(OpenWorktreeAction.rustrover.bundleIdentifier == "com.jetbrains.rustrover") + #expect(OpenWorktreeAction.rider.bundleIdentifier == "com.jetbrains.rider") + #expect(OpenWorktreeAction.goland.bundleIdentifier == "com.jetbrains.goland") + #expect(OpenWorktreeAction.clion.bundleIdentifier == "com.jetbrains.CLion") + #expect(OpenWorktreeAction.phpstorm.bundleIdentifier == "com.jetbrains.PhpStorm") + #expect(OpenWorktreeAction.rubymine.bundleIdentifier == "com.jetbrains.rubymine") + } + + @Test func newActionsHaveCorrectBundleIdentifiers() { + #expect(OpenWorktreeAction.iterm2.bundleIdentifier == "com.googlecode.iterm2") + #expect(OpenWorktreeAction.sublimeText.bundleIdentifier == "com.sublimetext.4") + #expect(OpenWorktreeAction.tower.bundleIdentifier == "com.fournova.Tower3") } @Test func jetBrainsIDEsAreInEditorPriority() { @@ -31,5 +48,84 @@ struct OpenWorktreeActionTests { #expect(editors.contains(.webstorm)) #expect(editors.contains(.pycharm)) #expect(editors.contains(.rustrover)) + #expect(editors.contains(.rider)) + #expect(editors.contains(.goland)) + #expect(editors.contains(.clion)) + #expect(editors.contains(.phpstorm)) + #expect(editors.contains(.rubymine)) + } + + @Test func projectKindsPreferMatchingSpecialistApps() { + #expect(WorktreeProjectKind.apple.preferredActions.first == .xcode) + #expect(WorktreeProjectKind.android.preferredActions == [.androidStudio, .intellij]) + #expect(WorktreeProjectKind.dotnet.preferredActions.first == .rider) + #expect(WorktreeProjectKind.golang.preferredActions.first == .goland) + #expect(WorktreeProjectKind.rust.preferredActions.first == .rustrover) + } + + @Test func preferredDefaultPicksXcodeForAppleProject() throws { + try withProjectDirectory(entries: ["Package.swift"]) { directory in + let installed: Set = [.xcode, .cursor, .vscode, .finder] + let action = OpenWorktreeAction.preferredDefault(for: directory) { installed.contains($0) } + #expect(action == .xcode) + } + } + + @Test func preferredDefaultPicksAndroidStudioForGradleProject() throws { + try withProjectDirectory(entries: ["settings.gradle.kts", "gradlew"]) { directory in + let installed: Set = [.androidStudio, .cursor, .xcode, .finder] + let action = OpenWorktreeAction.preferredDefault(for: directory) { installed.contains($0) } + #expect(action == .androidStudio) + } + } + + @Test func preferredDefaultFallsBackToSecondSpecialistThenGenericPriority() throws { + try withProjectDirectory(entries: ["build.gradle.kts"]) { directory in + let withIntellij: Set = [.intellij, .cursor, .finder] + let intellijPick = OpenWorktreeAction.preferredDefault(for: directory) { + withIntellij.contains($0) + } + #expect(intellijPick == .intellij) + + let withoutJetBrains: Set = [.cursor, .finder] + let genericPick = OpenWorktreeAction.preferredDefault(for: directory) { + withoutJetBrains.contains($0) + } + #expect(genericPick == .cursor) + } + } + + @Test func preferredDefaultIgnoresProjectKindWithoutMarkers() throws { + try withProjectDirectory(entries: ["README.md"]) { directory in + let installed: Set = [.xcode, .vscode, .finder] + let action = OpenWorktreeAction.preferredDefault(for: directory) { installed.contains($0) } + #expect(action == .vscode) + } + } + + @Test func preferredDefaultWithoutDirectoryUsesGenericPriority() { + let installed: Set = [.xcode, .zed, .finder] + let action = OpenWorktreeAction.preferredDefault(for: nil) { installed.contains($0) } + #expect(action == .zed) + } + + @Test func preferredDefaultFallsBackToFinderWhenNothingInstalled() { + let action = OpenWorktreeAction.preferredDefault(for: nil) { _ in false } + #expect(action == .finder) + } + + private func withProjectDirectory( + entries: [String], + body: (URL) throws -> Void + ) throws { + let fileManager = FileManager.default + let directory = fileManager.temporaryDirectory + .appending(path: "open-action-\(UUID().uuidString)") + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: directory) } + for entry in entries { + try Data().write(to: directory.appending(path: entry)) + } + try body(directory) } } diff --git a/supacodeTests/WorktreeProjectKindTests.swift b/supacodeTests/WorktreeProjectKindTests.swift new file mode 100644 index 00000000..fded7c13 --- /dev/null +++ b/supacodeTests/WorktreeProjectKindTests.swift @@ -0,0 +1,84 @@ +import Foundation +import Testing + +@testable import supacode + +struct WorktreeProjectKindTests { + @Test(arguments: [ + (["App.xcodeproj/"], WorktreeProjectKind.apple), + (["App.xcworkspace/"], WorktreeProjectKind.apple), + (["Package.swift"], WorktreeProjectKind.apple), + (["Project.swift"], WorktreeProjectKind.apple), + (["settings.gradle"], WorktreeProjectKind.android), + (["settings.gradle.kts"], WorktreeProjectKind.android), + (["build.gradle.kts"], WorktreeProjectKind.android), + (["gradlew"], WorktreeProjectKind.android), + (["App.sln"], WorktreeProjectKind.dotnet), + (["App.csproj"], WorktreeProjectKind.dotnet), + (["pom.xml"], WorktreeProjectKind.java), + (["go.mod"], WorktreeProjectKind.golang), + (["Cargo.toml"], WorktreeProjectKind.rust), + (["CMakeLists.txt"], WorktreeProjectKind.cpp), + (["composer.json"], WorktreeProjectKind.php), + (["Gemfile"], WorktreeProjectKind.ruby), + (["pyproject.toml"], WorktreeProjectKind.python), + (["requirements.txt"], WorktreeProjectKind.python), + (["package.json"], WorktreeProjectKind.web), + ]) + func detectsKindFromMarker(entries: [String], expected: WorktreeProjectKind) throws { + try withTemporaryDirectory(entries: entries) { directory in + #expect(WorktreeProjectKind.detect(at: directory) == expected) + } + } + + @Test(arguments: [ + (["App.xcodeproj/", "package.json"], WorktreeProjectKind.apple), + (["Package.swift", "package.json"], WorktreeProjectKind.apple), + (["gradlew", "package.json"], WorktreeProjectKind.android), + (["Cargo.toml", "package.json"], WorktreeProjectKind.rust), + (["go.mod", "CMakeLists.txt"], WorktreeProjectKind.golang), + (["composer.json", "package.json"], WorktreeProjectKind.php), + ]) + func specificMarkersWinOverGenericOnes(entries: [String], expected: WorktreeProjectKind) throws { + try withTemporaryDirectory(entries: entries) { directory in + #expect(WorktreeProjectKind.detect(at: directory) == expected) + } + } + + @Test func returnsNilWithoutMarkers() throws { + try withTemporaryDirectory(entries: ["README.md", "src/"]) { directory in + #expect(WorktreeProjectKind.detect(at: directory) == nil) + } + } + + @Test func returnsNilForMissingDirectory() { + let directory = FileManager.default.temporaryDirectory + .appending(path: "missing-\(UUID().uuidString)") + #expect(WorktreeProjectKind.detect(at: directory) == nil) + } + + /// Creates a temporary directory containing `entries` (a trailing slash + /// marks a subdirectory, like an `.xcodeproj` bundle) and removes it after + /// `body` runs. + private func withTemporaryDirectory( + entries: [String], + body: (URL) throws -> Void + ) throws { + let fileManager = FileManager.default + let directory = fileManager.temporaryDirectory + .appending(path: "project-kind-\(UUID().uuidString)") + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: directory) } + for entry in entries { + if entry.hasSuffix("/") { + try fileManager.createDirectory( + at: directory.appending(path: String(entry.dropLast())), + withIntermediateDirectories: true + ) + } else { + try Data().write(to: directory.appending(path: entry)) + } + } + try body(directory) + } +} -- 2.51.2 From 2940a94ed3cbecbe29f9bc25ebffccdb10c937b1 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 13 Jun 2026 10:45:11 +0900 Subject: [PATCH 2/4] Share the temporary project directory helper between tests --- supacodeTests/OpenWorktreeActionTests.swift | 22 +++---------- supacodeTests/TemporaryProjectDirectory.swift | 26 ++++++++++++++++ supacodeTests/WorktreeProjectKindTests.swift | 31 ++----------------- 3 files changed, 33 insertions(+), 46 deletions(-) create mode 100644 supacodeTests/TemporaryProjectDirectory.swift diff --git a/supacodeTests/OpenWorktreeActionTests.swift b/supacodeTests/OpenWorktreeActionTests.swift index b664b7b5..f5023985 100644 --- a/supacodeTests/OpenWorktreeActionTests.swift +++ b/supacodeTests/OpenWorktreeActionTests.swift @@ -64,7 +64,7 @@ struct OpenWorktreeActionTests { } @Test func preferredDefaultPicksXcodeForAppleProject() throws { - try withProjectDirectory(entries: ["Package.swift"]) { directory in + try withTemporaryProjectDirectory(entries: ["Package.swift"]) { directory in let installed: Set = [.xcode, .cursor, .vscode, .finder] let action = OpenWorktreeAction.preferredDefault(for: directory) { installed.contains($0) } #expect(action == .xcode) @@ -72,7 +72,7 @@ struct OpenWorktreeActionTests { } @Test func preferredDefaultPicksAndroidStudioForGradleProject() throws { - try withProjectDirectory(entries: ["settings.gradle.kts", "gradlew"]) { directory in + try withTemporaryProjectDirectory(entries: ["settings.gradle.kts", "gradlew"]) { directory in let installed: Set = [.androidStudio, .cursor, .xcode, .finder] let action = OpenWorktreeAction.preferredDefault(for: directory) { installed.contains($0) } #expect(action == .androidStudio) @@ -80,7 +80,7 @@ struct OpenWorktreeActionTests { } @Test func preferredDefaultFallsBackToSecondSpecialistThenGenericPriority() throws { - try withProjectDirectory(entries: ["build.gradle.kts"]) { directory in + try withTemporaryProjectDirectory(entries: ["build.gradle.kts"]) { directory in let withIntellij: Set = [.intellij, .cursor, .finder] let intellijPick = OpenWorktreeAction.preferredDefault(for: directory) { withIntellij.contains($0) @@ -96,7 +96,7 @@ struct OpenWorktreeActionTests { } @Test func preferredDefaultIgnoresProjectKindWithoutMarkers() throws { - try withProjectDirectory(entries: ["README.md"]) { directory in + try withTemporaryProjectDirectory(entries: ["README.md"]) { directory in let installed: Set = [.xcode, .vscode, .finder] let action = OpenWorktreeAction.preferredDefault(for: directory) { installed.contains($0) } #expect(action == .vscode) @@ -114,18 +114,4 @@ struct OpenWorktreeActionTests { #expect(action == .finder) } - private func withProjectDirectory( - entries: [String], - body: (URL) throws -> Void - ) throws { - let fileManager = FileManager.default - let directory = fileManager.temporaryDirectory - .appending(path: "open-action-\(UUID().uuidString)") - try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: directory) } - for entry in entries { - try Data().write(to: directory.appending(path: entry)) - } - try body(directory) - } } diff --git a/supacodeTests/TemporaryProjectDirectory.swift b/supacodeTests/TemporaryProjectDirectory.swift new file mode 100644 index 00000000..c8497d14 --- /dev/null +++ b/supacodeTests/TemporaryProjectDirectory.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Creates a temporary directory containing `entries` (a trailing slash marks +/// a subdirectory, like an `.xcodeproj` bundle) and removes it after `body` +/// runs. +func withTemporaryProjectDirectory( + entries: [String], + body: (URL) throws -> Void +) throws { + let fileManager = FileManager.default + let directory = fileManager.temporaryDirectory + .appending(path: "project-fixture-\(UUID().uuidString)") + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: directory) } + for entry in entries { + if entry.hasSuffix("/") { + try fileManager.createDirectory( + at: directory.appending(path: String(entry.dropLast())), + withIntermediateDirectories: true + ) + } else { + try Data().write(to: directory.appending(path: entry)) + } + } + try body(directory) +} diff --git a/supacodeTests/WorktreeProjectKindTests.swift b/supacodeTests/WorktreeProjectKindTests.swift index fded7c13..19e4e91f 100644 --- a/supacodeTests/WorktreeProjectKindTests.swift +++ b/supacodeTests/WorktreeProjectKindTests.swift @@ -26,7 +26,7 @@ struct WorktreeProjectKindTests { (["package.json"], WorktreeProjectKind.web), ]) func detectsKindFromMarker(entries: [String], expected: WorktreeProjectKind) throws { - try withTemporaryDirectory(entries: entries) { directory in + try withTemporaryProjectDirectory(entries: entries) { directory in #expect(WorktreeProjectKind.detect(at: directory) == expected) } } @@ -40,13 +40,13 @@ struct WorktreeProjectKindTests { (["composer.json", "package.json"], WorktreeProjectKind.php), ]) func specificMarkersWinOverGenericOnes(entries: [String], expected: WorktreeProjectKind) throws { - try withTemporaryDirectory(entries: entries) { directory in + try withTemporaryProjectDirectory(entries: entries) { directory in #expect(WorktreeProjectKind.detect(at: directory) == expected) } } @Test func returnsNilWithoutMarkers() throws { - try withTemporaryDirectory(entries: ["README.md", "src/"]) { directory in + try withTemporaryProjectDirectory(entries: ["README.md", "src/"]) { directory in #expect(WorktreeProjectKind.detect(at: directory) == nil) } } @@ -56,29 +56,4 @@ struct WorktreeProjectKindTests { .appending(path: "missing-\(UUID().uuidString)") #expect(WorktreeProjectKind.detect(at: directory) == nil) } - - /// Creates a temporary directory containing `entries` (a trailing slash - /// marks a subdirectory, like an `.xcodeproj` bundle) and removes it after - /// `body` runs. - private func withTemporaryDirectory( - entries: [String], - body: (URL) throws -> Void - ) throws { - let fileManager = FileManager.default - let directory = fileManager.temporaryDirectory - .appending(path: "project-kind-\(UUID().uuidString)") - try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: directory) } - for entry in entries { - if entry.hasSuffix("/") { - try fileManager.createDirectory( - at: directory.appending(path: String(entry.dropLast())), - withIntermediateDirectories: true - ) - } else { - try Data().write(to: directory.appending(path: entry)) - } - } - try body(directory) - } } -- 2.51.2 From e8d8ff488c965ce430a1bf9327ad344d4f52108c Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 13 Jun 2026 12:09:40 +0900 Subject: [PATCH 3/4] Add an Automatic entry to the Open In menu to clear a pinned app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picking an app from the toolbar's Open dropdown pins it to the repository's openActionID, and there was no way to undo that — the project-aware Automatic resolution became unreachable once a repo had ever been pinned. The global defaultEditorID could be reset to "auto" from settings, but the per-repo/per-folder pin could not. Add an "Automatic" entry at the top of the dropdown that writes openActionID back to "auto", re-resolves the open action (project-aware), and opens with it. Track openActionIsAutomatic in AppFeature state so the entry shows a checkmark while the repo is on automatic. --- docs/components/repositories-and-worktrees.md | 6 +- .../App/Reducer/AppFeature+Support.swift | 1 + .../Features/App/Reducer/AppFeature.swift | 25 +++++++ .../Views/WorktreeDetailToolbarViews.swift | 2 + .../Views/WorktreeDetailView.swift | 30 ++++++++- .../AppFeatureDefaultEditorTests.swift | 67 +++++++++++++++++++ .../AppFeaturePlainFolderTerminalTests.swift | 1 + 7 files changed, 127 insertions(+), 5 deletions(-) diff --git a/docs/components/repositories-and-worktrees.md b/docs/components/repositories-and-worktrees.md index 97ad77cd..b09e834e 100644 --- a/docs/components/repositories-and-worktrees.md +++ b/docs/components/repositories-and-worktrees.md @@ -146,8 +146,10 @@ type is detected), it falls back to the generic priority — your first installed editor (Cursor → Zed → VS Code → Windsurf → …), falling through to Xcode and then **Finder only when no preferred app is found**. Use the **Open** dropdown in the worktree's detail toolbar to pick a different app -(this pins it for the repo), or set a per-repo default (`openActionID`) / -global default (`defaultEditorID`). Prowl detects: Finder, Terminal, +(this pins it for the repo), or pick **Automatic** at the top of that dropdown +to clear the pin and return to project-aware selection. You can also set a +per-repo default (`openActionID`) / global default (`defaultEditorID`). Prowl +detects: Finder, Terminal, `$EDITOR`, VS Code (+ Insiders), VSCodium, Cursor, Zed, Windsurf, Antigravity, Sublime Text, Xcode, Android Studio, JetBrains IDEs (IntelliJ IDEA, WebStorm, PyCharm, RustRover, Rider, GoLand, CLion, PhpStorm, RubyMine), GitHub Desktop diff --git a/supacode/Features/App/Reducer/AppFeature+Support.swift b/supacode/Features/App/Reducer/AppFeature+Support.swift index 9f145d30..f59eddba 100644 --- a/supacode/Features/App/Reducer/AppFeature+Support.swift +++ b/supacode/Features/App/Reducer/AppFeature+Support.swift @@ -166,6 +166,7 @@ extension AppFeature { defaultEditorID: normalizedDefaultEditorID, workingDirectory: workingDirectory ) + state.openActionIsAutomatic = settings.openActionID == OpenWorktreeAction.automaticSettingsID state.selectedRunScript = settings.runScript } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index d42f665a..72eb44cb 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -13,6 +13,10 @@ struct AppFeature { var updates = UpdatesFeature.State() var commandPalette = CommandPaletteFeature.State() var openActionSelection: OpenWorktreeAction = .finder + /// Whether the selected worktree's repository resolves its open action + /// automatically (project-aware) rather than a user-pinned app. Drives the + /// "Automatic" entry's checkmark in the toolbar's Open menu. + var openActionIsAutomatic: Bool = true var selectedRunScript: String = "" var selectedCustomCommands: [UserCustomCommand] = [] var resolvedKeybindings: ResolvedKeybindingMap = .appDefaults @@ -49,6 +53,7 @@ struct AppFeature { case updates(UpdatesFeature.Action) case commandPalette(CommandPaletteFeature.Action) case openActionSelectionChanged(OpenWorktreeAction) + case openActionResetToAutomatic case worktreeSettingsLoaded(RepositorySettings, worktreeID: Worktree.ID) case worktreeUserSettingsLoaded(UserRepositorySettings, worktreeID: Worktree.ID) case openSelectedWorktree @@ -422,6 +427,8 @@ struct AppFeature { defaultEditorID: settings.defaultEditorID, workingDirectory: selectedWorktree.workingDirectory ) + state.openActionIsAutomatic = + repositorySettings.openActionID == OpenWorktreeAction.automaticSettingsID } state.resolvedKeybindings = resolvedKeybindings( settings: state.settings, @@ -533,6 +540,7 @@ struct AppFeature { case .openActionSelectionChanged(let action): state.openActionSelection = action + state.openActionIsAutomatic = false guard let worktree = state.repositories.selectedTerminalWorktree else { return .none } @@ -542,6 +550,22 @@ struct AppFeature { $repositorySettings.withLock { $0.openActionID = actionID } return .none + case .openActionResetToAutomatic: + guard let worktree = state.repositories.selectedTerminalWorktree else { + return .none + } + let rootURL = worktree.repositoryRootURL + @Shared(.repositorySettings(rootURL)) var repositorySettings + $repositorySettings.withLock { $0.openActionID = OpenWorktreeAction.automaticSettingsID } + @Shared(.settingsFile) var settingsFile + state.openActionSelection = OpenWorktreeAction.fromSettingsID( + OpenWorktreeAction.automaticSettingsID, + defaultEditorID: settingsFile.global.defaultEditorID, + workingDirectory: worktree.workingDirectory + ) + state.openActionIsAutomatic = true + return .send(.openSelectedWorktree) + case .openSelectedWorktree: return .send(.openWorktree(OpenWorktreeAction.availableSelection(state.openActionSelection))) @@ -726,6 +750,7 @@ struct AppFeature { let worktree = terminalWorktree(for: worktreeID, repositories: state.repositories) else { state.openActionSelection = .finder + state.openActionIsAutomatic = true state.selectedRunScript = "" state.selectedCustomCommands = [] state.resolvedKeybindings = resolvedKeybindings( diff --git a/supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift b/supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift index e490ff9e..9c42b758 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift @@ -232,6 +232,7 @@ private struct WorktreeToolbarPreview: View { notificationGroups: [], unseenNotificationWorktreeCount: 0, openActionSelection: .finder, + openActionIsAutomatic: true, showExtras: false, runScriptEnabled: true, runScriptIsRunning: false, @@ -271,6 +272,7 @@ private struct WorktreeToolbarPreview: View { onConsumeExternalRenamePrompt: { _ in }, onOpenWorktree: { _ in }, onOpenActionSelectionChanged: { _ in }, + onResetOpenActionToAutomatic: {}, onCopyPath: {}, onSelectNotification: { _, _ in }, onDismissAllNotifications: {}, diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 33a12150..abfb218e 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -10,6 +10,7 @@ struct WorktreeDetailView: View { let notificationGroups: [ToolbarNotificationRepositoryGroup] let unseenNotificationWorktreeCount: Int let openActionSelection: OpenWorktreeAction + let openActionIsAutomatic: Bool let showExtras: Bool let runScriptEnabled: Bool let runScriptIsRunning: Bool @@ -68,7 +69,6 @@ struct WorktreeDetailView: View { actionTargetWorktree != nil && loadingInfo == nil && !showsMultiSelectionSummary - let openActionSelection = state.openActionSelection let runScriptEnabled = hasActiveTerminalTarget let runScriptIsRunning = actionTargetWorktree.flatMap { state.runScriptStatusByWorktreeID[$0.id] } == true let customCommands = state.selectedCustomCommands @@ -110,7 +110,8 @@ struct WorktreeDetailView: View { selectedWorktree: selectedWorktree, notificationGroups: notificationGroups, unseenNotificationWorktreeCount: unseenNotificationWorktreeCount, - openActionSelection: openActionSelection, + openActionSelection: state.openActionSelection, + openActionIsAutomatic: state.openActionIsAutomatic, showExtras: commandKeyObserver.isPressed, runScriptEnabled: runScriptEnabled, runScriptIsRunning: runScriptIsRunning, @@ -177,6 +178,9 @@ struct WorktreeDetailView: View { onOpenActionSelectionChanged: { action in store.send(.openActionSelectionChanged(action)) }, + onResetOpenActionToAutomatic: { + store.send(.openActionResetToAutomatic) + }, onCopyPath: { guard let actionTargetWorktree else { return } NSPasteboard.general.clearContents() @@ -323,6 +327,7 @@ struct WorktreeDetailView: View { notificationGroups: input.notificationGroups, unseenNotificationWorktreeCount: input.unseenNotificationWorktreeCount, openActionSelection: input.openActionSelection, + openActionIsAutomatic: input.openActionIsAutomatic, showExtras: input.showExtras, runScriptEnabled: input.runScriptEnabled, runScriptIsRunning: input.runScriptIsRunning, @@ -748,6 +753,7 @@ struct WorktreeDetailView: View { let notificationGroups: [ToolbarNotificationRepositoryGroup] let unseenNotificationWorktreeCount: Int let openActionSelection: OpenWorktreeAction + let openActionIsAutomatic: Bool let showExtras: Bool let runScriptEnabled: Bool let runScriptIsRunning: Bool @@ -766,6 +772,7 @@ struct WorktreeDetailView: View { let onConsumeExternalRenamePrompt: (Int) -> Void let onOpenWorktree: (OpenWorktreeAction) -> Void let onOpenActionSelectionChanged: (OpenWorktreeAction) -> Void + let onResetOpenActionToAutomatic: () -> Void let onCopyPath: () -> Void let onSelectNotification: (Worktree.ID, WorktreeTerminalNotification) -> Void let onDismissAllNotifications: () -> Void @@ -815,6 +822,7 @@ struct WorktreeDetailView: View { ToolbarItemGroup { openMenu( openActionSelection: toolbarState.openActionSelection, + openActionIsAutomatic: toolbarState.openActionIsAutomatic, showExtras: toolbarState.showExtras ) } @@ -824,7 +832,11 @@ struct WorktreeDetailView: View { } @ViewBuilder - private func openMenu(openActionSelection: OpenWorktreeAction, showExtras: Bool) -> some View { + private func openMenu( + openActionSelection: OpenWorktreeAction, + openActionIsAutomatic: Bool, + showExtras: Bool + ) -> some View { let availableActions = OpenWorktreeAction.availableCases let resolvedOpenActionSelection = OpenWorktreeAction.availableSelection(openActionSelection) Button { @@ -838,6 +850,18 @@ struct WorktreeDetailView: View { .help(openActionHelpText(for: resolvedOpenActionSelection, isDefault: true)) Menu { + Button { + onResetOpenActionToAutomatic() + } label: { + if openActionIsAutomatic { + Label("Automatic", systemImage: "checkmark") + } else { + Text("Automatic") + } + } + .buttonStyle(.plain) + .help("Pick the app automatically based on the project type") + Divider() ForEach(availableActions) { action in let isDefault = action == resolvedOpenActionSelection Button { diff --git a/supacodeTests/AppFeatureDefaultEditorTests.swift b/supacodeTests/AppFeatureDefaultEditorTests.swift index d312c749..54672c2e 100644 --- a/supacodeTests/AppFeatureDefaultEditorTests.swift +++ b/supacodeTests/AppFeatureDefaultEditorTests.swift @@ -93,6 +93,7 @@ struct AppFeatureDefaultEditorTests { await store.send(.repositories(.delegate(.selectedWorktreeChanged(worktree)))) await store.receive(\.worktreeSettingsLoaded) { $0.openActionSelection = .terminal + $0.openActionIsAutomatic = false $0.selectedRunScript = "pnpm dev" } await store.receive(\.worktreeUserSettingsLoaded) @@ -170,6 +171,72 @@ struct AppFeatureDefaultEditorTests { #expect(watcherCommands.value == [.setSelectedWorktreeID(worktree.id)]) } + @Test(.dependencies) func selectingAppPinsItAndClearsAutomatic() async { + let worktree = makeWorktree() + let repositoriesState = makeRepositoriesState(worktree: worktree) + let storage = SettingsTestStorage() + let localStorage = RepositoryLocalSettingsTestStorage() + let settingsFileURL = URL( + fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json" + ) + let store = TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } withDependencies: { + $0.settingsFileStorage = storage.storage + $0.settingsFileURL = settingsFileURL + $0.repositoryLocalSettingsStorage = localStorage.storage + } + + await store.send(.openActionSelectionChanged(.terminal)) { + $0.openActionSelection = .terminal + $0.openActionIsAutomatic = false + } + await store.finish() + } + + @Test(.dependencies) func resetToAutomaticClearsRepositoryPinAndReopens() async { + let worktree = makeWorktree() + let repositoriesState = makeRepositoriesState(worktree: worktree) + let storage = SettingsTestStorage() + let localStorage = RepositoryLocalSettingsTestStorage() + let settingsFileURL = URL( + fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json" + ) + let store = withDependencies { + $0.settingsFileStorage = storage.storage + $0.settingsFileURL = settingsFileURL + $0.repositoryLocalSettingsStorage = localStorage.storage + } operation: { + var settings = GlobalSettings.default + settings.defaultEditorID = OpenWorktreeAction.finder.settingsID + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = settings } + var initialState = AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State(settings: settings) + ) + // Simulate a worktree whose repository has pinned a specific app. + initialState.openActionSelection = .terminal + initialState.openActionIsAutomatic = false + return TestStore(initialState: initialState) { + AppFeature() + } + } + store.exhaustivity = .off + + await store.send(.openActionResetToAutomatic) { + $0.openActionSelection = .finder + $0.openActionIsAutomatic = true + } + await store.receive(\.openSelectedWorktree) + await store.receive(\.openWorktree) + } + private func makeWorktree() -> Worktree { let repositoryRootURL = URL(fileURLWithPath: "/tmp/repo-\(UUID().uuidString)") let worktreeURL = repositoryRootURL.appending(path: "wt-1") diff --git a/supacodeTests/AppFeaturePlainFolderTerminalTests.swift b/supacodeTests/AppFeaturePlainFolderTerminalTests.swift index 23fb948b..42a48dbd 100644 --- a/supacodeTests/AppFeaturePlainFolderTerminalTests.swift +++ b/supacodeTests/AppFeaturePlainFolderTerminalTests.swift @@ -70,6 +70,7 @@ struct AppFeaturePlainFolderTerminalTests { await store.receive(\.repositories.delegate.selectedWorktreeChanged) await store.receive(\.worktreeSettingsLoaded) { $0.openActionSelection = .terminal + $0.openActionIsAutomatic = false $0.selectedRunScript = "pnpm dev" } await store.receive(\.worktreeUserSettingsLoaded) { -- 2.51.2 From 3a24efcede24db7deabac5d2795d3315f946748c Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 13 Jun 2026 14:45:35 +0900 Subject: [PATCH 4/4] Address Copilot review on the Open In changes - Make the menu icon cache's MainActor isolation explicit. Every access is during SwiftUI rendering on the main thread, so the cache was already race-free via the module's default actor isolation; the attribute makes that contract compiler-enforced and documents why a lock is the wrong tool here (NSImage isn't Sendable). - Skip the redundant repository-settings write when resetting an already-automatic repo to Automatic, while still re-resolving and reopening so the entry stays consistent with the concrete app rows. --- supacode/Domain/OpenWorktreeAction.swift | 7 ++++++- supacode/Features/App/Reducer/AppFeature.swift | 11 ++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/supacode/Domain/OpenWorktreeAction.swift b/supacode/Domain/OpenWorktreeAction.swift index bac70a38..5ca5d18f 100644 --- a/supacode/Domain/OpenWorktreeAction.swift +++ b/supacode/Domain/OpenWorktreeAction.swift @@ -103,7 +103,12 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { // icons constantly. Only hits are cached so a newly installed app shows up // without invalidation; lookup misses are microseconds. private static let menuIconSize = CGSize(width: 16, height: 16) - private static var menuIconCache: [String: MenuIcon] = [:] + // `@MainActor` makes the cache's isolation explicit and compiler-enforced: + // every `menuIcon` access happens during SwiftUI rendering on the main + // thread, so this shared mutable state is never touched concurrently. (A + // lock would be the wrong tool here — `NSImage` isn't `Sendable`, so the + // cached values shouldn't cross threads in the first place.) + @MainActor private static var menuIconCache: [String: MenuIcon] = [:] var menuIcon: MenuIcon? { switch self { diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 72eb44cb..756b8b0c 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -554,9 +554,14 @@ struct AppFeature { guard let worktree = state.repositories.selectedTerminalWorktree else { return .none } - let rootURL = worktree.repositoryRootURL - @Shared(.repositorySettings(rootURL)) var repositorySettings - $repositorySettings.withLock { $0.openActionID = OpenWorktreeAction.automaticSettingsID } + // Clearing the pin only matters when the repo isn't already automatic; + // re-resolve and reopen unconditionally so the entry behaves like the + // concrete app rows, where selecting always opens. + if !state.openActionIsAutomatic { + let rootURL = worktree.repositoryRootURL + @Shared(.repositorySettings(rootURL)) var repositorySettings + $repositorySettings.withLock { $0.openActionID = OpenWorktreeAction.automaticSettingsID } + } @Shared(.settingsFile) var settingsFile state.openActionSelection = OpenWorktreeAction.fromSettingsID( OpenWorktreeAction.automaticSettingsID,