From e423d2d80ce9d73f8cb9ba0797ff81d57e3056b9 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 13 Jun 2026 10:37:11 +0900 Subject: [PATCH] Make Automatic Open In project-aware and expand supported apps - Automatic now detects the project kind from the worktree's top-level files (Xcode project / Package.swift -> Xcode, Gradle -> Android Studio, go.mod -> GoLand, Cargo.toml -> RustRover, ...) and prefers a matching app before the generic priority. Explicit per-repo or global selections are respected as before. - Add iTerm2, Sublime Text, Tower, Rider, GoLand, CLion, PhpStorm, and RubyMine to the Open In menu. - Cache pre-resized Open In menu icons: toolbar redraws previously reloaded and re-rasterized app icons on every render, while urlForApplication lookups measured cheap and stay uncached. --- docs/components/repositories-and-worktrees.md | 30 +++-- docs/reference/keyboard-shortcuts.md | 2 +- docs/reference/settings-fields.md | 4 +- supacode/Domain/OpenWorktreeAction.swift | 112 +++++++++++++++--- supacode/Domain/WorktreeProjectKind.swift | 90 ++++++++++++++ .../App/Reducer/AppFeature+Support.swift | 9 +- .../Features/App/Reducer/AppFeature.swift | 19 ++- .../OpenWorktreeActionMenuLabelView.swift | 15 +-- .../Views/AppearanceSettingsView.swift | 5 +- .../AppFeatureDefaultEditorTests.swift | 39 ++++++ supacodeTests/OpenWorktreeActionTests.swift | 96 +++++++++++++++ supacodeTests/WorktreeProjectKindTests.swift | 84 +++++++++++++ 12 files changed, 456 insertions(+), 49 deletions(-) create mode 100644 supacode/Domain/WorktreeProjectKind.swift create mode 100644 supacodeTests/WorktreeProjectKindTests.swift 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