diff --git a/SupacodeSettingsShared/Domain/OpenWorktreeAction.swift b/SupacodeSettingsShared/Domain/OpenWorktreeAction.swift index ee4956639..a30078ee0 100644 --- a/SupacodeSettingsShared/Domain/OpenWorktreeAction.swift +++ b/SupacodeSettingsShared/Domain/OpenWorktreeAction.swift @@ -1,5 +1,45 @@ import AppKit +public enum OpenTarget: Equatable, Sendable { + case workingDirectory + case url(URL) + case search(String, excludeDirectories: String? = nil, maxDepth: Int = 3) + + public static let `default`: Self = .workingDirectory +} + +public enum OpenBehavior: Equatable, Sendable { + public struct WorkspaceConfiguration: Equatable, Sendable { + public var createsNewApplicationInstance: Bool + public var arguments: [Argument] + + public init( + createsNewApplicationInstance: Bool = false, + arguments: [Argument] = [] + ) { + self.createsNewApplicationInstance = createsNewApplicationInstance + self.arguments = arguments + } + } + + public enum ProcessExecutable: Equatable, Sendable { + case path(String) + case appRelativePath(String) + } + + public enum Argument: Equatable, Sendable { + case literal(String) + case appPath + case targetPath + case targetURL + } + + case workspace(configuration: WorkspaceConfiguration? = nil) + case process(ProcessExecutable, args: [Argument]) + + public static let `default`: Self = .workspace(configuration: nil) +} + public enum OpenWorktreeAction: CaseIterable, Identifiable { public enum MenuIcon { case app(NSImage) @@ -175,6 +215,49 @@ public enum OpenWorktreeAction: CaseIterable, Identifiable { } } + public var openTargets: [OpenTarget] { + switch self { + case .xcode: + [ + .search(#"\.xcworkspace$"#, excludeDirectories: Self.xcodeSearchExcludedDirectories), + .search(#"\.xcodeproj$"#, excludeDirectories: Self.xcodeSearchExcludedDirectories), + .default, + ] + case .alacritty, .androidStudio, .antigravity, .cursor, .editor, .finder, .fork, .githubDesktop, + .gitkraken, .gitup, .ghostty, .intellij, .kitty, .pycharm, .rubymine, .rustrover, .smartgit, + .sourcetree, .sublimeMerge, .terminal, .vscode, .vscodeInsiders, .vscodium, .warp, .webstorm, + .wezterm, .windsurf, .zed: + [.default] + } + } + + public var openBehaviors: [OpenBehavior] { + switch self { + case .androidStudio, .intellij, .webstorm, .pycharm, .rubymine, .rustrover: + [ + .workspace( + configuration: + .init( + createsNewApplicationInstance: true, + arguments: [.targetPath] + ) + ) + ] + case .zed: + [ + .process( + .appRelativePath("Contents/MacOS/cli"), + args: [.targetPath] + ), + .default, + ] + case .alacritty, .antigravity, .cursor, .editor, .finder, .fork, .githubDesktop, .gitkraken, .gitup, + .ghostty, .kitty, .smartgit, .sourcetree, .sublimeMerge, .terminal, .vscode, .vscodeInsiders, + .vscodium, .warp, .wezterm, .windsurf, .xcode: + [.default] + } + } + public nonisolated static let automaticSettingsID = "auto" public static let editorPriority: [OpenWorktreeAction] = [ @@ -255,4 +338,11 @@ public enum OpenWorktreeAction: CaseIterable, Identifiable { public static func preferredDefault() -> OpenWorktreeAction { defaultPriority.first(where: \.isInstalled) ?? .finder } + + private static let xcodeSearchExcludedDirectories = + #"(^|/)("# + + #"\.build|\.dart_tool|\.expo|\.expo-shared|\.git|\.gradle|\.pnpm-store"# + + #"|\.swiftpm|\.symlinks|\.yarn|Carthage|DerivedData|Pods|build|node_modules"# + + #"|[^/]+\.xcodeproj|[^/]+\.xcworkspace"# + + #")(/|$)"# } diff --git a/supacode/Clients/Workspace/WorkspaceClient.swift b/supacode/Clients/Workspace/WorkspaceClient.swift index 317c1e310..745bc5d05 100644 --- a/supacode/Clients/Workspace/WorkspaceClient.swift +++ b/supacode/Clients/Workspace/WorkspaceClient.swift @@ -13,7 +13,7 @@ struct WorkspaceClient { extension WorkspaceClient: DependencyKey { static let liveValue = WorkspaceClient { action, worktree, onError in - performOpenWorktreeAction(action: action, worktree: worktree, onError: onError) + WorktreeOpener.perform(action: action, worktree: worktree, onError: onError) } static let testValue = WorkspaceClient { _, _, _ in } @@ -26,71 +26,343 @@ extension DependencyValues { } } -private func performOpenWorktreeAction( - action: OpenWorktreeAction, - worktree: Worktree, - onError: @escaping @MainActor @Sendable (OpenActionError) -> Void -) { - let actionTitle = action.title - switch action { - case .editor: - return - case .finder: - NSWorkspace.shared.activateFileViewerSelecting([worktree.workingDirectory]) - case .androidStudio, .intellij, .webstorm, .pycharm, .rubymine, .rustrover: - guard let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: action.bundleIdentifier) else { +@MainActor +enum WorktreeOpener { + static func perform( + action: OpenWorktreeAction, + worktree: Worktree, + onError: @escaping @MainActor @Sendable (OpenActionError) -> Void + ) { + guard action != .editor else { + return + } + guard let targetURL = WorkspaceOpenResolver.resolveFirstTarget(for: action.openTargets, worktree: worktree) else { onError( OpenActionError( - title: "\(action.title) not found", - message: "Install \(action.title) to open this worktree." + title: "Unable to open in \(action.title)", + message: "No matching target was found for this worktree." ) ) return } - let configuration = NSWorkspace.OpenConfiguration() - configuration.createsNewApplicationInstance = true - configuration.arguments = [worktree.workingDirectory.path] - NSWorkspace.shared.openApplication(at: appURL, configuration: configuration) { _, error in - guard let error else { - return - } - Task { @MainActor in - onError( - OpenActionError( - title: "Unable to open in \(actionTitle)", - message: error.localizedDescription - ) - ) - } + guard action != .finder else { + NSWorkspace.shared.activateFileViewerSelecting([targetURL]) + return } - case .alacritty, .antigravity, .cursor, .fork, .githubDesktop, .gitkraken, .gitup, .ghostty, - .kitty, .smartgit, .sourcetree, .sublimeMerge, .terminal, .vscode, .vscodeInsiders, - .vscodium, .warp, .wezterm, .windsurf, .xcode, .zed: guard let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: action.bundleIdentifier) else { - onError( - OpenActionError( - title: "\(action.title) not found", - message: "Install \(action.title) to open this worktree." + onError(.appNotFound(action)) + return + } + for behavior in action.openBehaviors { + switch behavior { + case .workspace(let configuration): + openWithWorkspace( + action: action, + configuration: configuration, + appURL: appURL, + targetURL: targetURL, + onError: onError ) + return + case .process(let executable, let args): + switch openWithProcess(executable: executable, args: args, appURL: appURL, targetURL: targetURL) { + case .launched: + return + case .unavailable: + continue + case .failed(let error): + onError(.openFailed(action, error)) + return + } + } + } + onError( + OpenActionError( + title: "Unable to open in \(action.title)", + message: "No supported open behavior was available for this worktree." + ) + ) + } + + private enum BehaviorOpenResult { + case launched + case unavailable + case failed(Error) + } + + private static func openWithWorkspace( + action: OpenWorktreeAction, + configuration: OpenBehavior.WorkspaceConfiguration?, + appURL: URL, + targetURL: URL, + onError: @escaping @MainActor @Sendable (OpenActionError) -> Void + ) { + let workspaceConfiguration = workspaceOpenConfiguration( + configuration, + appURL: appURL, + targetURL: targetURL + ) + guard configuration?.arguments.isEmpty == false else { + openTargetWithWorkspace( + action: action, + configuration: workspaceConfiguration, + appURL: appURL, + targetURL: targetURL, + onError: onError ) return } + NSWorkspace.shared.openApplication(at: appURL, configuration: workspaceConfiguration) { _, error in + guard let error else { + return + } + Task { @MainActor in + onError(.openFailed(action, error)) + } + } + } + + private static func openTargetWithWorkspace( + action: OpenWorktreeAction, + configuration: NSWorkspace.OpenConfiguration, + appURL: URL, + targetURL: URL, + onError: @escaping @MainActor @Sendable (OpenActionError) -> Void + ) { NSWorkspace.shared.open( - [worktree.workingDirectory], + [targetURL], withApplicationAt: appURL, - configuration: .init() + configuration: configuration ) { _, error in guard let error else { return } Task { @MainActor in - onError( - OpenActionError( - title: "Unable to open in \(actionTitle)", - message: error.localizedDescription - ) + onError(.openFailed(action, error)) + } + } + } + + private static func openWithProcess( + executable: OpenBehavior.ProcessExecutable, + args: [OpenBehavior.Argument], + appURL: URL, + targetURL: URL + ) -> BehaviorOpenResult { + guard let invocation = processInvocation(executable: executable, appURL: appURL) else { + return .unavailable + } + let process = Process() + process.executableURL = invocation.executableURL + process.arguments = + invocation.argumentPrefix + args.map { resolvedOpenArgument($0, appURL: appURL, targetURL: targetURL) } + do { + try process.run() + return .launched + } catch { + return .failed(error) + } + } + + static func processInvocation( + executable: OpenBehavior.ProcessExecutable, + appURL: URL, + fileManager: FileManager = .default + ) -> (executableURL: URL, argumentPrefix: [String])? { + switch executable { + case .appRelativePath(let relativePath): + let executableURL = appRelativeURL(appURL: appURL, relativePath: relativePath) + guard fileManager.fileExists(atPath: executableURL.path(percentEncoded: false)) else { + return nil + } + return (executableURL, []) + case .path(let path): + if path.hasPrefix("/") { + return (URL(fileURLWithPath: path), []) + } + return (URL(fileURLWithPath: "/usr/bin/env"), [path]) + } + } + + private static func appRelativeURL(appURL: URL, relativePath: String) -> URL { + relativePath.split(separator: "/", omittingEmptySubsequences: true).reduce(appURL) { url, component in + url.appending(path: String(component)) + } + } + + private static func workspaceOpenConfiguration( + _ configuration: OpenBehavior.WorkspaceConfiguration?, + appURL: URL, + targetURL: URL + ) -> NSWorkspace.OpenConfiguration { + let workspaceConfiguration = NSWorkspace.OpenConfiguration() + guard let configuration else { + return workspaceConfiguration + } + workspaceConfiguration.createsNewApplicationInstance = configuration.createsNewApplicationInstance + workspaceConfiguration.arguments = configuration.arguments.map { + resolvedOpenArgument($0, appURL: appURL, targetURL: targetURL) + } + return workspaceConfiguration + } + + private static func resolvedOpenArgument( + _ argument: OpenBehavior.Argument, + appURL: URL, + targetURL: URL + ) -> String { + switch argument { + case .literal(let value): + value + case .appPath: + appURL.path(percentEncoded: false) + case .targetPath: + targetURL.path(percentEncoded: false) + case .targetURL: + targetURL.absoluteString + } + } +} + +extension OpenActionError { + static func appNotFound(_ action: OpenWorktreeAction) -> OpenActionError { + OpenActionError( + title: "\(action.title) not found", + message: "Install \(action.title) to open this worktree." + ) + } + + static func openFailed(_ action: OpenWorktreeAction, _ error: Error) -> OpenActionError { + OpenActionError( + title: "Unable to open in \(action.title)", + message: error.localizedDescription + ) + } +} + +enum WorkspaceOpenResolver { + static func resolveFirstTarget( + for targets: [OpenTarget], + worktree: Worktree, + fileManager: FileManager = .default + ) -> URL? { + for target in targets { + if let resolved = resolve(target, worktree: worktree, fileManager: fileManager) { + return resolved + } + } + return nil + } + + private static func resolve( + _ target: OpenTarget, + worktree: Worktree, + fileManager: FileManager + ) -> URL? { + switch target { + case .workingDirectory: + worktree.workingDirectory + case .url(let url): + url + case .search(let pattern, let excludeDirectories, let maxDepth): + search( + in: worktree.workingDirectory, + matching: pattern, + excludeDirectories: excludeDirectories, + maxDepth: maxDepth, + fileManager: fileManager + ) + } + } + + private static func search( + in rootURL: URL, + matching pattern: String, + excludeDirectories: String?, + maxDepth: Int, + fileManager: FileManager + ) -> URL? { + guard let targetRegex = try? Regex(pattern) else { + return nil + } + let excludeRegex = excludeDirectories.flatMap { try? Regex($0) } + guard maxDepth > 0 else { + return nil + } + var directories = [SearchDirectory(url: rootURL, relativePath: "")] + for depth in 1...maxDepth { + let entries = directories.flatMap { directory in + searchEntries( + in: directory, + fileManager: fileManager ) } + .sorted { lhs, rhs in + lhs.relativePath.localizedCaseInsensitiveCompare(rhs.relativePath) == .orderedAscending + } + + for entry in entries where matches(targetRegex, in: entry.relativePath) { + return entry.url + } + + directories = entries.compactMap { entry in + guard depth < maxDepth, entry.isDirectory, !entry.isPackage else { + return nil + } + if let excludeRegex, matches(excludeRegex, in: entry.relativePath) { + return nil + } + return SearchDirectory(url: entry.url, relativePath: entry.relativePath) + } + } + return nil + } + + private struct SearchDirectory { + let url: URL + let relativePath: String + } + + private struct SearchEntry { + let url: URL + let relativePath: String + let isDirectory: Bool + let isPackage: Bool + } + + private static func searchEntries( + in directory: SearchDirectory, + fileManager: FileManager + ) -> [SearchEntry] { + guard + let childURLs = try? fileManager.contentsOfDirectory( + at: directory.url, + includingPropertiesForKeys: [.isDirectoryKey, .isPackageKey], + options: [.skipsHiddenFiles] + ) + else { + return [] + } + return childURLs.map { childURL in + let relativePath = childRelativePath(directory: directory, childURL: childURL) + let resourceValues = try? childURL.resourceValues(forKeys: [.isDirectoryKey, .isPackageKey]) + return SearchEntry( + url: childURL, + relativePath: relativePath, + isDirectory: resourceValues?.isDirectory ?? false, + isPackage: resourceValues?.isPackage ?? false + ) + } + } + + private static func matches(_ regex: Regex, in value: String) -> Bool { + value.firstMatch(of: regex) != nil + } + + private static func childRelativePath(directory: SearchDirectory, childURL: URL) -> String { + guard !directory.relativePath.isEmpty else { + return childURL.lastPathComponent } + return directory.relativePath + "/" + childURL.lastPathComponent } } diff --git a/supacodeTests/OpenWorktreeActionTests.swift b/supacodeTests/OpenWorktreeActionTests.swift index 698a90612..96ea5c132 100644 --- a/supacodeTests/OpenWorktreeActionTests.swift +++ b/supacodeTests/OpenWorktreeActionTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import SupacodeSettingsShared @@ -36,4 +37,237 @@ struct OpenWorktreeActionTests { #expect(editors.contains(.rubymine)) #expect(editors.contains(.rustrover)) } + + @Test func xcodeOpenTargetsSearchWorkspaceThenProjectThenWorkingDirectory() { + let targets = OpenWorktreeAction.xcode.openTargets + + #expect(targets.count == 3) + guard case .search(let workspacePattern, let workspaceExclusions, let workspaceMaxDepth) = targets[0] else { + #expect(Bool(false), "Xcode should search for workspaces first.") + return + } + guard case .search(let projectPattern, let projectExclusions, let projectMaxDepth) = targets[1] else { + #expect(Bool(false), "Xcode should search for projects second.") + return + } + #expect(workspacePattern == #"\.xcworkspace$"#) + for excludedDirectory in [ + #"\.build"#, + #"\.dart_tool"#, + #"\.expo"#, + #"\.expo-shared"#, + #"\.git"#, + #"\.gradle"#, + #"\.pnpm-store"#, + #"\.swiftpm"#, + #"\.symlinks"#, + #"\.yarn"#, + "Carthage", + "DerivedData", + "Pods", + "build", + "node_modules", + #"\.xcodeproj"#, + #"\.xcworkspace"#, + ] { + #expect(workspaceExclusions?.contains(excludedDirectory) == true) + } + #expect(workspaceMaxDepth == 3) + #expect(projectPattern == #"\.xcodeproj$"#) + #expect(projectExclusions == workspaceExclusions) + #expect(projectMaxDepth == workspaceMaxDepth) + #expect(targets[2] == .default) + #expect(OpenTarget.default == .workingDirectory) + } + + @Test func jetBrainsIDEsUseConfiguredWorkspaceOpenBehavior() { + for action in [ + OpenWorktreeAction.androidStudio, + .intellij, + .webstorm, + .pycharm, + .rubymine, + .rustrover, + ] { + guard action.openBehaviors.count == 1, + case .workspace(let configuration) = action.openBehaviors[0], + let configuration + else { + #expect(Bool(false), "\(action.title) should use workspace opening.") + continue + } + #expect(configuration.createsNewApplicationInstance) + #expect(configuration.arguments == [.targetPath]) + } + } + + @Test func zedUsesBundledCLIThenWorkspaceOpenBehavior() { + let behaviors = OpenWorktreeAction.zed.openBehaviors + + #expect(behaviors.count == 2) + #expect( + behaviors.first + == .process( + .appRelativePath("Contents/MacOS/cli"), + args: [.targetPath] + ) + ) + #expect(behaviors.last == .default) + #expect(OpenBehavior.default == .workspace(configuration: nil)) + } + + @MainActor + @Test func appRelativeProcessExecutableResolvesOnlyWhenPresent() throws { + let rootURL = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: rootURL) } + let appURL = rootURL.appending(path: "Zed.app") + let cliURL = appURL.appending(path: "Contents/MacOS/cli") + try FileManager.default.createDirectory( + at: cliURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + #expect(FileManager.default.createFile(atPath: cliURL.path(percentEncoded: false), contents: Data())) + + let present = WorktreeOpener.processInvocation( + executable: .appRelativePath("Contents/MacOS/cli"), + appURL: appURL + ) + let missing = WorktreeOpener.processInvocation( + executable: .appRelativePath("Contents/MacOS/missing"), + appURL: appURL + ) + + #expect(Self.standardizedPath(present?.executableURL) == Self.standardizedPath(cliURL)) + #expect(present?.argumentPrefix == []) + #expect(missing == nil) + } + + @MainActor + @Test func worktreeOpenerNoopsEditorAction() { + var errors: [OpenActionError] = [] + + WorktreeOpener.perform( + action: .editor, + worktree: Self.makeWorktree(at: URL(filePath: "/tmp/repo")), + onError: { errors.append($0) } + ) + + #expect(errors.isEmpty) + } + + @Test func resolverSkipsExcludedSearchDirectoriesAndFallsBackToNextTarget() throws { + let rootURL = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: rootURL) } + try FileManager.default.createDirectory( + at: rootURL.appending(path: "Pods/Generated.xcworkspace"), + withIntermediateDirectories: true + ) + let projectURL = rootURL.appending(path: "Supacode.xcodeproj") + try FileManager.default.createDirectory(at: projectURL, withIntermediateDirectories: true) + + let resolved = WorkspaceOpenResolver.resolveFirstTarget( + for: [ + .search(#"\.xcworkspace$"#, excludeDirectories: #"(^|/)Pods(/|$)"#), + .search(#"\.xcodeproj$"#, excludeDirectories: nil), + .workingDirectory, + ], + worktree: Self.makeWorktree(at: rootURL) + ) + + #expect(Self.standardizedPath(resolved) == Self.standardizedPath(projectURL)) + } + + @Test func xcodeResolverDoesNotDescendIntoProjectPackages() throws { + let rootURL = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: rootURL) } + let projectURL = rootURL.appending(path: "Supacode.xcodeproj") + try FileManager.default.createDirectory( + at: projectURL.appending(path: "project.xcworkspace"), + withIntermediateDirectories: true + ) + + let resolved = WorkspaceOpenResolver.resolveFirstTarget( + for: OpenWorktreeAction.xcode.openTargets, + worktree: Self.makeWorktree(at: rootURL) + ) + + #expect(Self.standardizedPath(resolved) == Self.standardizedPath(projectURL)) + } + + @Test func xcodeResolverStillReturnsTopLevelWorkspacePackages() throws { + let rootURL = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: rootURL) } + let workspaceURL = rootURL.appending(path: "Supacode.xcworkspace") + try FileManager.default.createDirectory(at: workspaceURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: rootURL.appending(path: "Supacode.xcodeproj/project.xcworkspace"), + withIntermediateDirectories: true + ) + + let resolved = WorkspaceOpenResolver.resolveFirstTarget( + for: OpenWorktreeAction.xcode.openTargets, + worktree: Self.makeWorktree(at: rootURL) + ) + + #expect(Self.standardizedPath(resolved) == Self.standardizedPath(workspaceURL)) + } + + @Test func resolverOnlyUsesWorkingDirectoryWhenItIsAnExplicitFallback() throws { + let rootURL = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: rootURL) } + let worktree = Self.makeWorktree(at: rootURL) + + let searchOnly = WorkspaceOpenResolver.resolveFirstTarget( + for: [.search(#"\.xcworkspace$"#, excludeDirectories: nil)], + worktree: worktree + ) + let withExplicitFallback = WorkspaceOpenResolver.resolveFirstTarget( + for: [.search(#"\.xcworkspace$"#, excludeDirectories: nil), .workingDirectory], + worktree: worktree + ) + + #expect(searchOnly == nil) + #expect(Self.standardizedPath(withExplicitFallback) == Self.standardizedPath(rootURL)) + } + + @Test func resolverHonorsSearchMaxDepth() throws { + let rootURL = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: rootURL) } + let deepProjectURL = rootURL.appending(path: "Examples/macOS/App/Supacode.xcodeproj") + try FileManager.default.createDirectory(at: deepProjectURL, withIntermediateDirectories: true) + + let defaultDepth = WorkspaceOpenResolver.resolveFirstTarget( + for: [.search(#"\.xcodeproj$"#)], + worktree: Self.makeWorktree(at: rootURL) + ) + let deeperDepth = WorkspaceOpenResolver.resolveFirstTarget( + for: [.search(#"\.xcodeproj$"#, maxDepth: 4)], + worktree: Self.makeWorktree(at: rootURL) + ) + + #expect(defaultDepth == nil) + #expect(Self.standardizedPath(deeperDepth) == Self.standardizedPath(deepProjectURL)) + } + + private static func makeTemporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory.appending( + path: "supacode-open-target-\(UUID().uuidString)" + ) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private static func makeWorktree(at rootURL: URL) -> Worktree { + Worktree( + id: WorktreeID(rootURL.path(percentEncoded: false)), + name: rootURL.lastPathComponent, + detail: "detail", + workingDirectory: rootURL, + repositoryRootURL: rootURL + ) + } + + private static func standardizedPath(_ url: URL?) -> String? { + url?.standardizedFileURL.path(percentEncoded: false) + } }