native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #139 from onevcat/friday/issue-106-cli-open-launch

feat(cli): auto-launch Prowl when app is not running

authored by

Wei Wang and committed by
GitHub
e278bcab 37eafb2b

+1356 -217
+149
ProwlCLI/AppLauncher.swift
··· 1 + // ProwlCLI/AppLauncher.swift 2 + // Launches Prowl.app when CLI detects the app is not running, then waits 3 + // for the CLI socket to become available. 4 + 5 + #if canImport(Darwin) 6 + import Darwin 7 + #elseif canImport(Glibc) 8 + import Glibc 9 + #endif 10 + import Foundation 11 + import ProwlCLIShared 12 + 13 + enum AppLauncher { 14 + /// Maximum time to wait for the socket after launching the app. 15 + private static let socketTimeoutSeconds: TimeInterval = 15 16 + /// Interval between socket availability checks. 17 + private static let pollIntervalSeconds: TimeInterval = 0.25 18 + 19 + /// Check whether the CLI socket is currently connectable. 20 + static func isSocketAvailable() -> Bool { 21 + canConnect(to: ProwlSocket.defaultPath) 22 + } 23 + 24 + /// Ensure the app is running and the socket is ready. 25 + /// Returns `true` only when the app was not running and had to be launched. 26 + /// 27 + /// When `PROWL_CLI_SOCKET` is set (e.g. integration tests), auto-launch is 28 + /// disabled — the caller controls the socket endpoint. 29 + static func ensureAppRunning() throws -> Bool { 30 + // If a custom socket path is set, skip auto-launch entirely. 31 + if let override = ProcessInfo.processInfo.environment[ProwlSocket.environmentKey], 32 + !override.isEmpty 33 + { 34 + return false 35 + } 36 + 37 + // Fast path: socket file exists and is connectable. 38 + if isSocketAvailable() { 39 + return false 40 + } 41 + 42 + // Socket not available — check if the app process is running. 43 + if isAppProcessRunning() { 44 + // App is running but socket isn't ready yet. Wait without launching. 45 + try waitForSocket() 46 + return false 47 + } 48 + 49 + // App is genuinely not running. Launch and wait. 50 + try launchApp() 51 + try waitForSocket() 52 + return true 53 + } 54 + 55 + // MARK: - Process detection 56 + 57 + /// Check whether a Prowl process is currently running via `pgrep`. 58 + private static func isAppProcessRunning() -> Bool { 59 + let process = Process() 60 + process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep") 61 + process.arguments = ["-x", "Prowl"] 62 + process.standardOutput = FileHandle.nullDevice 63 + process.standardError = FileHandle.nullDevice 64 + do { 65 + try process.run() 66 + process.waitUntilExit() 67 + } catch { 68 + return false 69 + } 70 + return process.terminationStatus == 0 71 + } 72 + 73 + // MARK: - App launch 74 + 75 + private static func launchApp() throws { 76 + let process = Process() 77 + process.executableURL = URL(fileURLWithPath: "/usr/bin/open") 78 + process.arguments = launchArguments() 79 + process.standardOutput = FileHandle.nullDevice 80 + process.standardError = FileHandle.nullDevice 81 + do { 82 + try process.run() 83 + process.waitUntilExit() 84 + } catch { 85 + throw ExitError( 86 + code: CLIErrorCode.launchFailed, 87 + message: "Failed to launch Prowl: \(error.localizedDescription)" 88 + ) 89 + } 90 + guard process.terminationStatus == 0 else { 91 + throw ExitError( 92 + code: CLIErrorCode.launchFailed, 93 + message: "Failed to launch Prowl (exit code \(process.terminationStatus))." 94 + ) 95 + } 96 + } 97 + 98 + private static func launchArguments() -> [String] { 99 + var args = ["-a", "Prowl"] 100 + if let openPath = ProcessInfo.processInfo.environment[ProwlSocket.cliOpenPathEnvironmentKey], 101 + !openPath.isEmpty 102 + { 103 + args.append(contentsOf: ["--args", ProwlSocket.cliOpenPathArgument, openPath]) 104 + } 105 + return args 106 + } 107 + 108 + // MARK: - Socket readiness 109 + 110 + private static func waitForSocket() throws { 111 + let socketPath = ProwlSocket.defaultPath 112 + let deadline = Date().addingTimeInterval(socketTimeoutSeconds) 113 + while Date() < deadline { 114 + if canConnect(to: socketPath) { 115 + return 116 + } 117 + Thread.sleep(forTimeInterval: pollIntervalSeconds) 118 + } 119 + throw ExitError( 120 + code: CLIErrorCode.launchFailed, 121 + message: "Prowl CLI socket did not become available within \(Int(socketTimeoutSeconds))s." 122 + ) 123 + } 124 + 125 + private static func canConnect(to socketPath: String) -> Bool { 126 + let socketFD = socket(AF_UNIX, SOCK_STREAM, 0) 127 + guard socketFD >= 0 else { return false } 128 + defer { close(socketFD) } 129 + 130 + var addr = sockaddr_un() 131 + addr.sun_family = sa_family_t(AF_UNIX) 132 + let pathBytes = Array(socketPath.utf8) 133 + let maxLen = MemoryLayout.size(ofValue: addr.sun_path) - 1 134 + let copyLen = min(pathBytes.count, maxLen) 135 + withUnsafeMutableBytes(of: &addr.sun_path) { sunPathPtr in 136 + for idx in 0..<copyLen { 137 + sunPathPtr[idx] = pathBytes[idx] 138 + } 139 + sunPathPtr[copyLen] = 0 140 + } 141 + 142 + let result = withUnsafePointer(to: &addr) { ptr in 143 + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in 144 + connect(socketFD, sockPtr, socklen_t(MemoryLayout<sockaddr_un>.size)) 145 + } 146 + } 147 + return result == 0 148 + } 149 + }
+25 -3
ProwlCLI/Commands/OpenCommand.swift
··· 18 18 mutating func run() throws { 19 19 try CLIExecution.run(command: "open", output: options.outputMode) { 20 20 let resolvedPath: String? = try path.map { try normalizePath($0) } 21 + let invocation = Self.deriveInvocation(path: resolvedPath) 22 + 23 + if let resolvedPath { 24 + setenv(ProwlSocket.cliOpenPathEnvironmentKey, resolvedPath, 1) 25 + } else { 26 + unsetenv(ProwlSocket.cliOpenPathEnvironmentKey) 27 + } 28 + 29 + // If the app is not running, launch it first. 30 + let appLaunched = try Self.ensureAppRunning() 31 + 21 32 let envelope = CommandEnvelope( 22 33 output: options.outputMode, 23 - command: .open(OpenInput(path: resolvedPath, invocation: Self.deriveInvocation(path: resolvedPath))) 34 + command: .open(OpenInput( 35 + path: resolvedPath, 36 + invocation: invocation, 37 + appLaunched: appLaunched 38 + )) 24 39 ) 25 40 try CLIRunner.execute(envelope) 26 41 } 27 42 } 28 43 44 + /// Check if Prowl is reachable via socket; if not, launch it. 45 + /// Returns `true` only when the app was not running and had to be launched. 46 + private static func ensureAppRunning() throws -> Bool { 47 + try AppLauncher.ensureAppRunning() 48 + } 49 + 29 50 private static func deriveInvocation(path: String?) -> String { 30 51 guard path != nil else { return "bare" } 31 - let args = CommandLine.arguments.dropFirst() 32 - if args.first == "open" { 52 + let args = ProcessInfo.processInfo.arguments.dropFirst() 53 + let firstPositional = args.first { !$0.hasPrefix("-") } 54 + if firstPositional == "open" { 33 55 return "open-subcommand" 34 56 } 35 57 return "implicit-open"
+4
ProwlCLI/Output/OutputRenderer.swift
··· 81 81 return 82 82 } 83 83 84 + if response.command == "open" { 85 + return 86 + } 87 + 84 88 print("ok: \(response.command)") 85 89 return 86 90 }
+42 -2
ProwlCLITests/ProwlCLIIntegrationTests.swift
··· 44 44 ok: true, 45 45 command: "open", 46 46 schemaVersion: "prowl.cli.open.v1", 47 - data: RawJSON(encoding: OpenPayload( 47 + data: RawJSON(encoding: OpenResponseData( 48 + invocation: "open-subcommand", 49 + requestedPath: repoRoot.path, 50 + resolvedPath: repoRoot.path, 48 51 resolution: "exact-root", 52 + appLaunched: false, 49 53 broughtToFront: true 50 54 )) 51 55 ) ··· 60 64 let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) 61 65 if case .open(let input) = envelope.command { 62 66 let openedPath = try XCTUnwrap(input.path) 67 + XCTAssertEqual(input.invocation, "open-subcommand") 63 68 XCTAssertEqual( 64 69 URL(fileURLWithPath: openedPath).resolvingSymlinksInPath().path, 65 70 repoRoot.resolvingSymlinksInPath().path ··· 71 76 let payload = try jsonObject(from: result.stdout) 72 77 XCTAssertEqual(payload["ok"] as? Bool, true) 73 78 XCTAssertEqual(payload["command"] as? String, "open") 79 + } 80 + 81 + func testOpenCommandTextSuccessIsSilent() throws { 82 + let socketPath = temporarySocketPath(suffix: "open-text") 83 + let response = try CommandResponse( 84 + ok: true, 85 + command: "open", 86 + schemaVersion: "prowl.cli.open.v1", 87 + data: RawJSON(encoding: OpenResponseData( 88 + invocation: "implicit-open", 89 + requestedPath: repoRoot.path, 90 + resolvedPath: repoRoot.path, 91 + resolution: "exact-root", 92 + appLaunched: false, 93 + broughtToFront: true 94 + )) 95 + ) 96 + 97 + let (_, result) = try runWithMockServer( 98 + socketPath: socketPath, 99 + response: response, 100 + args: ["."] 101 + ) 102 + 103 + XCTAssertEqual(result.exitCode, 0) 104 + XCTAssertEqual(result.stdout, "") 105 + XCTAssertEqual(result.stderr, "") 74 106 } 75 107 76 108 func testFocusCommandRoundTripsOverSocket() throws { ··· 1191 1223 } 1192 1224 } 1193 1225 1194 - private struct OpenPayload: Encodable { 1226 + private struct OpenResponseData: Encodable { 1227 + let invocation: String 1228 + let requestedPath: String? 1229 + let resolvedPath: String? 1195 1230 let resolution: String 1231 + let appLaunched: Bool 1196 1232 1197 1233 enum CodingKeys: String, CodingKey { 1234 + case invocation 1235 + case requestedPath = "requested_path" 1236 + case resolvedPath = "resolved_path" 1198 1237 case resolution 1238 + case appLaunched = "app_launched" 1199 1239 case broughtToFront = "brought_to_front" 1200 1240 } 1201 1241
+6 -2
doc-onevcat/contracts/cli/open.md
··· 17 17 - Success output must tell an agent **what Prowl actually focused or opened**. 18 18 - Path fields must be normalized to **absolute paths** after CLI parsing. 19 19 - Success output must be stable enough for scripts; human-oriented prose belongs to non-JSON mode. 20 + - Human-visible success output for `open` should stay silent unless the caller asked for `--json`. 20 21 21 22 ## Success payload 22 23 ··· 87 88 - `created_tab`: boolean. 88 89 - `true` when the operation created a new tab as part of satisfying the request. 89 90 - `false` when Prowl only focused an existing target. 90 - - `target`: object describing the final focused target. 91 + - `target`: object or `null`, describing the final focused target. 92 + - May be `null` for bare `prowl` when Prowl was only brought to front and there is no resolvable visible surface yet. 91 93 92 94 ## `target` shape 95 + 96 + When present, `target` has the following shape: 93 97 94 98 ### `target.worktree` 95 99 ··· 150 154 151 155 - Success JSON should report the **resolved target**, not only the input path. 152 156 - `target.tab.cwd` and `target.pane.cwd` should be the exact directory the user lands in when available. 153 - - `created_tab` may be `false` for `exact-root` and `true` for `inside-root`; `new-root` may do either depending on implementation, so callers must trust the boolean instead of inferring it. 157 + - `created_tab` may be `false` for `exact-root` and `true` for `inside-root`; callers must trust the boolean instead of inferring it from `resolution`. 154 158 - `prowl` without arguments still returns `command: "open"`; it is the app-entry form of the same capability. 155 159 156 160 ## Example: exact-root focus
+17 -1
doc-onevcat/contracts/cli/schema.md
··· 63 63 "title": { "type": "string" } 64 64 } 65 65 }, 66 + "tabWithCwd": { 67 + "allOf": [ 68 + { "$ref": "#/$defs/tabBasic" }, 69 + { 70 + "type": "object", 71 + "additionalProperties": false, 72 + "required": ["cwd"], 73 + "properties": { 74 + "cwd": { 75 + "type": ["string", "null"], 76 + "pattern": "^/.*" 77 + } 78 + } 79 + } 80 + ] 81 + }, 66 82 "paneBasic": { 67 83 "type": "object", 68 84 "additionalProperties": false, ··· 108 124 "required": ["worktree", "tab", "pane"], 109 125 "properties": { 110 126 "worktree": { "$ref": "#/$defs/worktree" }, 111 - "tab": { "$ref": "#/$defs/tabBasic" }, 127 + "tab": { "$ref": "#/$defs/tabWithCwd" }, 112 128 "pane": { "$ref": "#/$defs/paneBasic" } 113 129 } 114 130 },
+75 -42
supacode/App/supacodeApp.swift
··· 96 96 @State private var cliSocketServer: CLISocketServer 97 97 @State private var store: StoreOf<AppFeature> 98 98 99 + private static func cliLaunchOpenPath() -> String? { 100 + let args = ProcessInfo.processInfo.arguments 101 + guard let flagIndex = args.firstIndex(of: ProwlSocket.cliOpenPathArgument), 102 + args.indices.contains(flagIndex + 1) 103 + else { 104 + return nil 105 + } 106 + let path = args[flagIndex + 1] 107 + return path.isEmpty ? nil : path 108 + } 109 + 99 110 @MainActor init() { 100 111 NSWindow.allowsAutomaticWindowTabbing = false 101 112 UserDefaults.standard.set(200, forKey: "NSInitialToolTipDelay") ··· 148 159 _worktreeInfoWatcher = State(initialValue: worktreeInfoWatcher) 149 160 let keyObserver = CommandKeyObserver() 150 161 _commandKeyObserver = State(initialValue: keyObserver) 162 + var initialAppState = AppFeature.State(settings: SettingsFeature.State(settings: initialSettings)) 163 + if let cliOpenPath = Self.cliLaunchOpenPath() { 164 + initialAppState.launchRestoreMode = .cliOpenPath(cliOpenPath) 165 + } 151 166 let appStore = Store( 152 - initialState: AppFeature.State(settings: SettingsFeature.State(settings: initialSettings)) 167 + initialState: initialAppState 153 168 ) { 154 169 AppFeature() 155 170 .logActions() ··· 205 220 } 206 221 207 222 // swiftlint:disable:next function_body_length 208 - private static func makeCLISocketServer( 223 + static func makeCLICommandRouter( 209 224 appStore: StoreOf<AppFeature>, 210 225 terminalManager: WorktreeTerminalManager 211 - ) -> CLISocketServer { 226 + ) -> CLICommandRouter { 212 227 let listHandler = ListCommandHandler { 213 228 ListRuntimeSnapshotBuilder.makeSnapshot( 214 229 repositoriesState: appStore.state.repositories, ··· 311 326 return KeyDeliveryResult(attempted: repeatCount, delivered: delivered) 312 327 } 313 328 ) 314 - let cliRouter = CLICommandRouter( 329 + return CLICommandRouter( 315 330 openHandler: openHandler, 316 331 listHandler: listHandler, 317 332 focusHandler: focusHandler, ··· 319 334 keyHandler: keyHandler, 320 335 readHandler: readHandler 321 336 ) 337 + } 338 + 339 + private static func makeCLISocketServer( 340 + appStore: StoreOf<AppFeature>, 341 + terminalManager: WorktreeTerminalManager 342 + ) -> CLISocketServer { 343 + let cliRouter = makeCLICommandRouter(appStore: appStore, terminalManager: terminalManager) 322 344 let cliServer = CLISocketServer(router: cliRouter) 323 345 let logger = SupaLogger("CLIService") 324 346 do { ··· 351 373 appStore.send(.repositories(.repositoryManagement(.openRepositories([url])))) 352 374 }, 353 375 createTabAtPath: { worktreeID, path in 354 - // Find the Worktree object by ID to create a tab cd'd to the subpath. 355 - let repositories = appStore.state.repositories 356 - for repository in repositories.repositories { 357 - if let worktree = repository.worktrees.first(where: { $0.id == worktreeID }) { 358 - let quotedPath = shellQuote(path) 359 - terminalManager.handleCommand( 360 - .createTabWithInput( 361 - worktree, 362 - input: "cd -- \(quotedPath)", 363 - runSetupScriptIfNew: false 364 - ) 365 - ) 366 - return 367 - } 376 + let repositories = Array(appStore.state.repositories.repositories) 377 + guard let worktree = resolveCLITerminalWorktree(id: worktreeID, repositories: repositories) else { 378 + return 379 + } 380 + terminalManager.handleCommand( 381 + .createTabInDirectory(worktree, directory: URL(fileURLWithPath: path, isDirectory: true)) 382 + ) 383 + }, 384 + resolveTarget: { selector in 385 + switch makeTargetResolver(appStore: appStore, terminalManager: terminalManager).resolve(selector) { 386 + case .success(let target): 387 + return OpenResolvedTarget( 388 + worktreeID: target.worktreeID, 389 + worktreeName: target.worktreeName, 390 + worktreePath: target.worktreePath, 391 + worktreeRootPath: target.worktreeRootPath, 392 + worktreeKind: target.worktreeKind.rawValue, 393 + tabID: target.tabID.uuidString, 394 + tabTitle: target.tabTitle, 395 + tabCWD: target.paneCWD, 396 + paneID: target.paneID.uuidString, 397 + paneTitle: target.paneTitle, 398 + paneCWD: target.paneCWD 399 + ) 400 + case .failure: 401 + return nil 368 402 } 369 403 }, 370 - terminalSnapshot: { worktreeID in 371 - guard let state = terminalManager.activeWorktreeStates.first( 372 - where: { $0.worktreeID == worktreeID } 373 - ) else { return nil } 374 - let snapshot = state.makeCLIListSnapshot() 375 - guard let selectedTab = snapshot.tabs.first(where: { $0.selected }) ?? snapshot.tabs.first 376 - else { return nil } 377 - let focusedPane = selectedTab.panes.first(where: { $0.id == selectedTab.focusedPaneID }) 378 - ?? selectedTab.panes.first 379 - return OpenTerminalSnapshot( 380 - tabID: selectedTab.id.uuidString, 381 - tabTitle: selectedTab.title, 382 - tabCwd: selectedTab.panes.first.flatMap { $0.cwd }, 383 - paneID: focusedPane?.id.uuidString, 384 - paneTitle: focusedPane?.title, 385 - paneCwd: focusedPane?.cwd 386 - ) 404 + isRepositoriesReady: { 405 + appStore.state.repositories.isInitialLoadComplete 387 406 } 388 407 ) 389 408 } ··· 469 488 ) 470 489 } 471 490 472 - private static func shellQuote(_ value: String) -> String { 473 - let needsQuoting = value.contains { character in 474 - character.isWhitespace || character == "\"" || character == "'" || character == "\\" 475 - } 476 - guard needsQuoting else { 477 - return value 491 + static func resolveCLITerminalWorktree( 492 + id: Worktree.ID, 493 + repositories: [Repository] 494 + ) -> Worktree? { 495 + for repository in repositories { 496 + if let worktree = repository.worktrees[id: id] { 497 + return worktree 498 + } 499 + if repository.id == id, 500 + repository.capabilities.supportsRunnableFolderActions, 501 + !repository.capabilities.supportsWorktrees 502 + { 503 + return Worktree( 504 + id: repository.id, 505 + name: repository.name, 506 + detail: repository.rootURL.path(percentEncoded: false), 507 + workingDirectory: repository.rootURL, 508 + repositoryRootURL: repository.rootURL 509 + ) 510 + } 478 511 } 479 - return "'\(value.replacing("'", with: "'\"'\"'"))'" 512 + return nil 480 513 } 481 514 482 515 private static func selectCLIWorktreeContext(
+333 -90
supacode/CLIService/OpenCommandHandler.swift
··· 53 53 case createdTab = "created_tab" 54 54 case target 55 55 } 56 + 57 + func encode(to encoder: Encoder) throws { 58 + var container = encoder.container(keyedBy: CodingKeys.self) 59 + try container.encode(invocation, forKey: .invocation) 60 + if let requestedPath { 61 + try container.encode(requestedPath, forKey: .requestedPath) 62 + } else { 63 + try container.encodeNil(forKey: .requestedPath) 64 + } 65 + if let resolvedPath { 66 + try container.encode(resolvedPath, forKey: .resolvedPath) 67 + } else { 68 + try container.encodeNil(forKey: .resolvedPath) 69 + } 70 + try container.encode(resolution, forKey: .resolution) 71 + try container.encode(appLaunched, forKey: .appLaunched) 72 + try container.encode(broughtToFront, forKey: .broughtToFront) 73 + try container.encode(createdTab, forKey: .createdTab) 74 + if let target { 75 + try container.encode(target, forKey: .target) 76 + } else { 77 + try container.encodeNil(forKey: .target) 78 + } 79 + } 56 80 } 57 81 58 82 struct OpenTarget: Codable { 59 83 let worktree: OpenTargetWorktree 60 - let tab: OpenTargetTab? 61 - let pane: OpenTargetPane? 84 + let tab: OpenTargetTab 85 + let pane: OpenTargetPane 62 86 } 63 87 64 88 struct OpenTargetWorktree: Codable { ··· 79 103 let id: String 80 104 let title: String 81 105 let cwd: String? 106 + 107 + enum CodingKeys: String, CodingKey { 108 + case id 109 + case title 110 + case cwd 111 + } 112 + 113 + func encode(to encoder: Encoder) throws { 114 + var container = encoder.container(keyedBy: CodingKeys.self) 115 + try container.encode(id, forKey: .id) 116 + try container.encode(title, forKey: .title) 117 + if let cwd { 118 + try container.encode(cwd, forKey: .cwd) 119 + } else { 120 + try container.encodeNil(forKey: .cwd) 121 + } 122 + } 82 123 } 83 124 84 125 struct OpenTargetPane: Codable { 85 126 let id: String 86 127 let title: String 87 128 let cwd: String? 88 - } 89 129 90 - // MARK: - Terminal snapshot for open command 130 + enum CodingKeys: String, CodingKey { 131 + case id 132 + case title 133 + case cwd 134 + } 91 135 92 - struct OpenTerminalSnapshot: Sendable { 93 - let tabID: String? 94 - let tabTitle: String? 95 - let tabCwd: String? 96 - let paneID: String? 97 - let paneTitle: String? 98 - let paneCwd: String? 136 + func encode(to encoder: Encoder) throws { 137 + var container = encoder.container(keyedBy: CodingKeys.self) 138 + try container.encode(id, forKey: .id) 139 + try container.encode(title, forKey: .title) 140 + if let cwd { 141 + try container.encode(cwd, forKey: .cwd) 142 + } else { 143 + try container.encodeNil(forKey: .cwd) 144 + } 145 + } 146 + } 147 + 148 + struct OpenResolvedTarget: Sendable { 149 + let worktreeID: String 150 + let worktreeName: String 151 + let worktreePath: String 152 + let worktreeRootPath: String 153 + let worktreeKind: String 154 + let tabID: String 155 + let tabTitle: String 156 + let tabCWD: String? 157 + let paneID: String 158 + let paneTitle: String 159 + let paneCWD: String? 99 160 } 100 161 101 162 // MARK: - Handler ··· 107 168 /// Creates a new tab in the given worktree and `cd`s to the specified path. 108 169 /// Parameters: worktreeID, absolutePath. 109 170 typealias CreateTabAtPathAction = @MainActor (String, String) -> Void 110 - typealias TerminalSnapshotProvider = @MainActor (String) -> OpenTerminalSnapshot? 171 + typealias ResolveTargetAction = @MainActor (TargetSelector) -> OpenResolvedTarget? 172 + typealias ReadinessProvider = @MainActor () -> Bool 173 + typealias SleepAction = @Sendable (UInt64) async -> Void 111 174 112 175 private let resolver: Resolver 113 176 private let selectWorktree: SelectAction 114 177 private let addAndOpen: AddAndOpenAction 115 178 private let createTabAtPath: CreateTabAtPathAction 116 - private let terminalSnapshot: TerminalSnapshotProvider 179 + private let resolveTarget: ResolveTargetAction 180 + private let isRepositoriesReady: ReadinessProvider 181 + private let sleep: SleepAction 182 + private let waitTimeoutNanoseconds: UInt64 183 + private let pollIntervalNanoseconds: UInt64 117 184 118 185 init( 119 186 resolver: @escaping Resolver, 120 187 selectWorktree: @escaping SelectAction, 121 188 addAndOpen: @escaping AddAndOpenAction, 122 189 createTabAtPath: @escaping CreateTabAtPathAction = { _, _ in }, 123 - terminalSnapshot: @escaping TerminalSnapshotProvider 190 + resolveTarget: @escaping ResolveTargetAction, 191 + isRepositoriesReady: @escaping ReadinessProvider = { true }, 192 + sleep: @escaping SleepAction = { nanoseconds in 193 + try? await Task.sleep(nanoseconds: nanoseconds) 194 + }, 195 + waitTimeoutNanoseconds: UInt64 = 10_000_000_000, 196 + pollIntervalNanoseconds: UInt64 = 50_000_000 124 197 ) { 125 198 self.resolver = resolver 126 199 self.selectWorktree = selectWorktree 127 200 self.addAndOpen = addAndOpen 128 201 self.createTabAtPath = createTabAtPath 129 - self.terminalSnapshot = terminalSnapshot 202 + self.resolveTarget = resolveTarget 203 + self.isRepositoriesReady = isRepositoriesReady 204 + self.sleep = sleep 205 + self.waitTimeoutNanoseconds = waitTimeoutNanoseconds 206 + self.pollIntervalNanoseconds = pollIntervalNanoseconds 130 207 } 131 208 132 - // swiftlint:disable:next async_without_await 133 209 func handle(envelope: CommandEnvelope) async -> CommandResponse { 134 210 guard case .open(let input) = envelope.command else { 135 211 return CommandResponse( ··· 140 216 ) 141 217 } 142 218 219 + await waitForRepositoriesReadyIfNeeded() 220 + 221 + let appLaunched = input.appLaunched 143 222 let result = resolver(input.path) 144 223 let invocation = deriveInvocation(input: input) 145 224 225 + return await handleResolvedResult( 226 + result, 227 + input: input, 228 + invocation: invocation, 229 + appLaunched: appLaunched 230 + ) 231 + } 232 + 233 + private func handleResolvedResult( 234 + _ result: OpenResolverResult, 235 + input: OpenInput, 236 + invocation: String, 237 + appLaunched: Bool 238 + ) async -> CommandResponse { 146 239 switch result.resolution { 147 240 case .noArgument: 148 - bringAppToFront() 149 - return makeSuccess( 150 - invocation: invocation, 151 - requestedPath: nil, 152 - resolvedPath: nil, 153 - resolution: .noArgument, 154 - createdTab: false, 155 - target: nil 156 - ) 157 - 241 + return handleNoArgument(invocation: invocation, appLaunched: appLaunched) 158 242 case .exactRoot: 159 - if let worktreeID = result.worktreeID { 160 - selectWorktree(worktreeID) 161 - } 162 - bringAppToFront() 163 - let snapshot = result.worktreeID.flatMap { terminalSnapshot($0) } 164 - return makeSuccess( 243 + return await handleExactRoot( 244 + result: result, 245 + input: input, 165 246 invocation: invocation, 166 - requestedPath: input.path, 167 - resolvedPath: result.resolvedPath ?? input.path, 168 - resolution: .exactRoot, 169 - createdTab: false, 170 - target: makeTarget(result: result, snapshot: snapshot) 247 + appLaunched: appLaunched 171 248 ) 172 - 173 249 case .insideRoot: 174 - if let worktreeID = result.worktreeID { 175 - selectWorktree(worktreeID) 176 - // Open a new tab cd'd to the exact requested subpath. 177 - if let subpath = result.resolvedPath ?? input.path { 178 - createTabAtPath(worktreeID, subpath) 179 - } 180 - } 181 - bringAppToFront() 182 - let snapshot = result.worktreeID.flatMap { terminalSnapshot($0) } 183 - return makeSuccess( 250 + return await handleInsideRoot( 251 + result: result, 252 + input: input, 184 253 invocation: invocation, 185 - requestedPath: input.path, 186 - resolvedPath: result.resolvedPath ?? input.path, 187 - resolution: .insideRoot, 188 - createdTab: true, 189 - target: makeTarget(result: result, snapshot: snapshot) 254 + appLaunched: appLaunched 190 255 ) 191 - 192 256 case .newRoot: 193 - if let path = result.resolvedPath ?? input.path { 194 - let url = URL(fileURLWithPath: path, isDirectory: true) 195 - addAndOpen(url) 196 - } 197 - bringAppToFront() 198 - return makeSuccess( 257 + return await handleNewRoot( 258 + result: result, 259 + input: input, 199 260 invocation: invocation, 200 - requestedPath: input.path, 201 - resolvedPath: result.resolvedPath ?? input.path, 202 - resolution: .newRoot, 203 - createdTab: true, 204 - target: nil 261 + appLaunched: appLaunched 262 + ) 263 + } 264 + } 265 + 266 + private func handleNoArgument(invocation: String, appLaunched: Bool) -> CommandResponse { 267 + bringAppToFront() 268 + let target = makeTarget(selector: .none) 269 + return makeSuccess( 270 + invocation: invocation, 271 + requestedPath: nil, 272 + resolvedPath: nil, 273 + resolution: .noArgument, 274 + appLaunched: appLaunched, 275 + createdTab: false, 276 + target: target 277 + ) 278 + } 279 + 280 + private func handleExactRoot( 281 + result: OpenResolverResult, 282 + input: OpenInput, 283 + invocation: String, 284 + appLaunched: Bool 285 + ) async -> CommandResponse { 286 + guard let worktreeID = result.worktreeID else { 287 + return makeFailure(message: "Resolved exact-root target is missing a worktree ID.") 288 + } 289 + let requestedPath = result.resolvedPath ?? input.path 290 + selectWorktree(worktreeID) 291 + bringAppToFront() 292 + 293 + var createdTab = false 294 + var target = makeTarget(selector: .worktree(worktreeID)) 295 + if target == nil, let requestedPath { 296 + createTabAtPath(worktreeID, requestedPath) 297 + createdTab = true 298 + target = await waitForOpenTarget( 299 + selector: .worktree(worktreeID), 300 + preferredPaneCWD: requestedPath 205 301 ) 206 302 } 303 + guard let target else { 304 + return makeFailure(message: "Failed to resolve the focused target for '\(worktreeID)'.") 305 + } 306 + return makeSuccess( 307 + invocation: invocation, 308 + requestedPath: input.path, 309 + resolvedPath: requestedPath, 310 + resolution: .exactRoot, 311 + appLaunched: appLaunched, 312 + createdTab: createdTab, 313 + target: target 314 + ) 315 + } 316 + 317 + private func handleInsideRoot( 318 + result: OpenResolverResult, 319 + input: OpenInput, 320 + invocation: String, 321 + appLaunched: Bool 322 + ) async -> CommandResponse { 323 + guard let worktreeID = result.worktreeID else { 324 + return makeFailure(message: "Resolved inside-root target is missing a worktree ID.") 325 + } 326 + selectWorktree(worktreeID) 327 + if let subpath = result.resolvedPath ?? input.path { 328 + createTabAtPath(worktreeID, subpath) 329 + } 330 + bringAppToFront() 331 + guard let target = await waitForOpenTarget( 332 + selector: .worktree(worktreeID), 333 + preferredPaneCWD: result.resolvedPath ?? input.path 334 + ) else { 335 + return makeFailure(message: "Failed to resolve the focused target for '\(worktreeID)'.") 336 + } 337 + return makeSuccess( 338 + invocation: invocation, 339 + requestedPath: input.path, 340 + resolvedPath: result.resolvedPath ?? input.path, 341 + resolution: .insideRoot, 342 + appLaunched: appLaunched, 343 + createdTab: true, 344 + target: target 345 + ) 346 + } 347 + 348 + private func handleNewRoot( 349 + result: OpenResolverResult, 350 + input: OpenInput, 351 + invocation: String, 352 + appLaunched: Bool 353 + ) async -> CommandResponse { 354 + guard let path = result.resolvedPath ?? input.path else { 355 + return makeFailure(message: "Resolved new-root target is missing a path.") 356 + } 357 + let url = URL(fileURLWithPath: path, isDirectory: true) 358 + addAndOpen(url) 359 + bringAppToFront() 360 + 361 + let finalResult = await waitForManagedResult(path: path) 362 + guard let worktreeID = finalResult?.worktreeID else { 363 + return makeFailure(message: "Failed to resolve the newly opened target for '\(path)'.") 364 + } 365 + 366 + selectWorktree(worktreeID) 367 + createTabAtPath(worktreeID, path) 368 + guard let target = await waitForOpenTarget( 369 + selector: .worktree(worktreeID), 370 + preferredPaneCWD: path 371 + ) else { 372 + return makeFailure(message: "Failed to resolve the newly opened target for '\(path)'.") 373 + } 374 + 375 + return makeSuccess( 376 + invocation: invocation, 377 + requestedPath: input.path, 378 + resolvedPath: result.resolvedPath ?? input.path, 379 + resolution: .newRoot, 380 + appLaunched: appLaunched, 381 + createdTab: true, 382 + target: target 383 + ) 207 384 } 208 385 209 386 // MARK: - Private ··· 225 402 } 226 403 } 227 404 228 - private func makeTarget( 229 - result: OpenResolverResult, 230 - snapshot: OpenTerminalSnapshot? 231 - ) -> OpenTarget? { 232 - guard let worktreeID = result.worktreeID, 233 - let worktreeName = result.worktreeName, 234 - let worktreePath = result.worktreePath, 235 - let rootPath = result.rootPath 236 - else { 237 - return nil 405 + private func waitForRepositoriesReadyIfNeeded() async { 406 + guard !isRepositoriesReady() else { return } 407 + 408 + for attempt in 0..<maxPollAttempts() { 409 + if isRepositoriesReady() { 410 + return 411 + } 412 + if attempt + 1 < maxPollAttempts() { 413 + await sleep(pollIntervalNanoseconds) 414 + } 415 + } 416 + } 417 + 418 + private func waitForManagedResult(path: String) async -> OpenResolverResult? { 419 + var lastResult: OpenResolverResult? 420 + 421 + for attempt in 0..<maxPollAttempts() { 422 + let result = resolver(path) 423 + lastResult = result 424 + if result.resolution != .newRoot, result.worktreeID != nil { 425 + return result 426 + } 427 + if attempt + 1 < maxPollAttempts() { 428 + await sleep(pollIntervalNanoseconds) 429 + } 430 + } 431 + 432 + if let lastResult, lastResult.resolution != .newRoot, lastResult.worktreeID != nil { 433 + return lastResult 238 434 } 435 + return nil 436 + } 239 437 240 - let worktreeTarget = OpenTargetWorktree( 241 - id: worktreeID, 242 - name: worktreeName, 243 - path: worktreePath, 244 - rootPath: rootPath, 245 - kind: result.worktreeKind ?? "git" 246 - ) 438 + private func waitForOpenTarget( 439 + selector: TargetSelector, 440 + preferredPaneCWD: String? = nil 441 + ) async -> OpenTarget? { 442 + var fallbackTarget: OpenTarget? 247 443 248 - let tabTarget: OpenTargetTab? = snapshot.flatMap { snap in 249 - guard let tabID = snap.tabID, let tabTitle = snap.tabTitle else { return nil } 250 - return OpenTargetTab(id: tabID, title: tabTitle, cwd: snap.tabCwd) 444 + for attempt in 0..<maxPollAttempts() { 445 + if let target = makeTarget(selector: selector) { 446 + fallbackTarget = target 447 + if preferredPaneCWD == nil || target.pane.cwd == preferredPaneCWD { 448 + return target 449 + } 450 + } 451 + if attempt + 1 < maxPollAttempts() { 452 + await sleep(pollIntervalNanoseconds) 453 + } 251 454 } 252 455 253 - let paneTarget: OpenTargetPane? = snapshot.flatMap { snap in 254 - guard let paneID = snap.paneID, let paneTitle = snap.paneTitle else { return nil } 255 - return OpenTargetPane(id: paneID, title: paneTitle, cwd: snap.paneCwd) 456 + return fallbackTarget 457 + } 458 + 459 + private func maxPollAttempts() -> Int { 460 + let interval = max(pollIntervalNanoseconds, 1) 461 + let attempts = waitTimeoutNanoseconds / interval 462 + return max(1, Int(attempts) + 1) 463 + } 464 + 465 + private func makeTarget(selector: TargetSelector) -> OpenTarget? { 466 + guard let resolved = resolveTarget(selector) else { 467 + return nil 256 468 } 257 469 258 - return OpenTarget(worktree: worktreeTarget, tab: tabTarget, pane: paneTarget) 470 + return OpenTarget( 471 + worktree: OpenTargetWorktree( 472 + id: resolved.worktreeID, 473 + name: resolved.worktreeName, 474 + path: resolved.worktreePath, 475 + rootPath: resolved.worktreeRootPath, 476 + kind: resolved.worktreeKind 477 + ), 478 + tab: OpenTargetTab( 479 + id: resolved.tabID, 480 + title: resolved.tabTitle, 481 + cwd: resolved.tabCWD 482 + ), 483 + pane: OpenTargetPane( 484 + id: resolved.paneID, 485 + title: resolved.paneTitle, 486 + cwd: resolved.paneCWD 487 + ) 488 + ) 489 + } 490 + 491 + private func makeFailure(message: String) -> CommandResponse { 492 + CommandResponse( 493 + ok: false, 494 + command: "open", 495 + schemaVersion: "prowl.cli.open.v1", 496 + error: CommandError( 497 + code: CLIErrorCode.openFailed, 498 + message: message 499 + ) 500 + ) 259 501 } 260 502 261 503 // swiftlint:disable:next function_parameter_count ··· 264 506 requestedPath: String?, 265 507 resolvedPath: String?, 266 508 resolution: OpenResolution, 509 + appLaunched: Bool, 267 510 createdTab: Bool, 268 511 target: OpenTarget? 269 512 ) -> CommandResponse { ··· 272 515 requestedPath: requestedPath, 273 516 resolvedPath: resolvedPath, 274 517 resolution: resolution.rawValue, 275 - appLaunched: false, 518 + appLaunched: appLaunched, 276 519 broughtToFront: true, 277 520 createdTab: createdTab, 278 521 target: target
+6 -1
supacode/CLIService/Shared/InputModels.swift
··· 11 11 /// Optional — handler derives a default if absent. 12 12 public let invocation: String? 13 13 14 - public init(path: String? = nil, invocation: String? = nil) { 14 + /// `true` when the CLI had to launch Prowl before sending this command. 15 + /// The handler copies this value into the response's `app_launched` field. 16 + public let appLaunched: Bool 17 + 18 + public init(path: String? = nil, invocation: String? = nil, appLaunched: Bool = false) { 15 19 self.path = path 16 20 self.invocation = invocation 21 + self.appLaunched = appLaunched 17 22 } 18 23 } 19 24
+8
supacode/CLIService/Shared/SocketConstants.swift
··· 7 7 /// Environment variable for overriding socket path. 8 8 public static let environmentKey = "PROWL_CLI_SOCKET" 9 9 10 + /// Environment key used only by CLI process to pass the normalized open path 11 + /// into app launch arguments during cold launch. 12 + public static let cliOpenPathEnvironmentKey = "PROWL_CLI_OPEN_PATH" 13 + 14 + /// App launch argument used by CLI open flow to pass the requested path 15 + /// during cold launch, so app startup can prefer CLI open behavior. 16 + public static let cliOpenPathArgument = "--prowl-cli-open-path" 17 + 10 18 /// Default Unix domain socket path. 11 19 /// Located in user's temporary directory to avoid permission issues. 12 20 ///
+1
supacode/Clients/Terminal/TerminalClient.swift
··· 9 9 enum Command: Equatable { 10 10 case createTab(Worktree, runSetupScriptIfNew: Bool) 11 11 case createTabWithInput(Worktree, input: String, runSetupScriptIfNew: Bool) 12 + case createTabInDirectory(Worktree, directory: URL) 12 13 case ensureInitialTab(Worktree, runSetupScriptIfNew: Bool, focusing: Bool) 13 14 case runScript(Worktree, script: String) 14 15 case insertText(Worktree, text: String)
+3 -1
supacode/Features/App/Models/LaunchRestoreMode.swift
··· 1 1 enum LaunchRestoreMode: Equatable, Sendable { 2 2 case lastFocusedWorktree 3 3 case restoreLayout 4 - // case openWorktree(Worktree.ID) // future CLI support 4 + /// Cold-launch path passed from `prowl open`. 5 + /// When set, startup should avoid restoring last focused worktree first. 6 + case cliOpenPath(String) 5 7 }
+2 -8
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 424 424 425 425 case .lastFocusedWorktreeIDLoaded(let lastFocusedWorktreeID): 426 426 state.lastFocusedWorktreeID = lastFocusedWorktreeID 427 - if state.launchRestoreMode != .restoreLayout { 427 + if state.launchRestoreMode == .lastFocusedWorktree { 428 428 state.shouldRestoreLastFocusedWorktree = true 429 429 } 430 430 return .none ··· 1833 1833 } 1834 1834 1835 1835 nonisolated func shellQuote(_ value: String) -> String { 1836 - let needsQuoting = value.contains { character in 1837 - character.isWhitespace || character == "\"" || character == "'" || character == "\\" 1838 - } 1839 - guard needsQuoting else { 1840 - return value 1841 - } 1842 - return "'\(value.replacing("'", with: "'\"'\"'"))'" 1836 + "'\(value.replacing("'", with: "'\"'\"'"))'" 1843 1837 } 1844 1838 1845 1839 private func updateWorktreeName(
+11 -2
supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift
··· 56 56 Task { 57 57 createTabAsync(in: worktree, runSetupScriptIfNew: runSetupScriptIfNew, initialInput: input) 58 58 } 59 + case .createTabInDirectory(let worktree, let directory): 60 + Task { 61 + createTabAsync(in: worktree, runSetupScriptIfNew: false, workingDirectory: directory) 62 + } 59 63 case .ensureInitialTab(let worktree, let runSetupScriptIfNew, let focusing): 60 64 let state = state(for: worktree) { runSetupScriptIfNew } 61 65 state.ensureInitialTab(focusing: focusing) ··· 239 243 private func createTabAsync( 240 244 in worktree: Worktree, 241 245 runSetupScriptIfNew: Bool, 242 - initialInput: String? = nil 246 + initialInput: String? = nil, 247 + workingDirectory: URL? = nil 243 248 ) { 244 249 let state = state(for: worktree) { runSetupScriptIfNew } 245 250 let setupScript: String? ··· 250 255 } else { 251 256 setupScript = nil 252 257 } 253 - _ = state.createTab(setupScript: setupScript, initialInput: initialInput) 258 + _ = state.createTab( 259 + setupScript: setupScript, 260 + initialInput: initialInput, 261 + workingDirectoryOverride: workingDirectory 262 + ) 254 263 } 255 264 256 265 @discardableResult
+10 -3
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 171 171 focusing: Bool = true, 172 172 setupScript: String? = nil, 173 173 initialInput: String? = nil, 174 - inheritingFromSurfaceId: UUID? = nil 174 + inheritingFromSurfaceId: UUID? = nil, 175 + workingDirectoryOverride: URL? = nil 175 176 ) -> TerminalTabID? { 176 177 let context: ghostty_surface_context_e = 177 178 tabManager.tabs.isEmpty ··· 204 205 initialInput: resolvedInput, 205 206 focusing: focusing, 206 207 inheritingFromSurfaceId: resolvedInheritanceSurfaceId, 207 - context: context 208 + context: context, 209 + workingDirectoryOverride: workingDirectoryOverride 208 210 ) 209 211 ) 210 212 if shouldConsumeSetupScript, tabId != nil { ··· 227 229 initialInput: input, 228 230 focusing: true, 229 231 inheritingFromSurfaceId: currentFocusedSurfaceId(), 230 - context: GHOSTTY_SURFACE_CONTEXT_TAB 232 + context: GHOSTTY_SURFACE_CONTEXT_TAB, 233 + workingDirectoryOverride: nil 231 234 ) 232 235 ) 233 236 setRunScriptTabId(tabId) ··· 249 252 let focusing: Bool 250 253 let inheritingFromSurfaceId: UUID? 251 254 let context: ghostty_surface_context_e 255 + let workingDirectoryOverride: URL? 252 256 } 253 257 254 258 private func createTab(_ creation: TabCreation) -> TerminalTabID? { ··· 261 265 for: tabId, 262 266 inheritingFromSurfaceId: creation.inheritingFromSurfaceId, 263 267 initialInput: creation.initialInput, 268 + workingDirectoryOverride: creation.workingDirectoryOverride, 264 269 context: creation.context 265 270 ) 266 271 tabIsRunningById[tabId] = false ··· 465 470 for tabId: TerminalTabID, 466 471 inheritingFromSurfaceId: UUID? = nil, 467 472 initialInput: String? = nil, 473 + workingDirectoryOverride: URL? = nil, 468 474 context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_TAB 469 475 ) -> SplitTree<GhosttySurfaceView> { 470 476 if let existing = trees[tabId] { ··· 474 480 tabId: tabId, 475 481 initialInput: initialInput, 476 482 inheritingFromSurfaceId: inheritingFromSurfaceId, 483 + workingDirectoryOverride: workingDirectoryOverride, 477 484 context: context 478 485 ) 479 486 let tree = SplitTree(view: surface)
+32
supacodeTests/AppFeatureTerminalLayoutRestoreTests.swift
··· 158 158 ) 159 159 } 160 160 161 + @Test(.dependencies) func repositoriesChangedSkipsLayoutRestoreForCliOpenMode() async { 162 + let worktree = makeWorktree() 163 + let repository = makeRepository(worktrees: [worktree]) 164 + var repositoriesState = RepositoriesFeature.State(repositories: [repository]) 165 + repositoriesState.snapshotPersistencePhase = .active 166 + var appState = AppFeature.State(repositories: repositoriesState, settings: SettingsFeature.State()) 167 + appState.launchRestoreMode = .cliOpenPath(worktree.workingDirectory.path(percentEncoded: false)) 168 + let sentCommands = LockIsolated<[TerminalClient.Command]>([]) 169 + 170 + let store = TestStore(initialState: appState) { 171 + AppFeature() 172 + } withDependencies: { 173 + $0.terminalClient.send = { command in 174 + sentCommands.withValue { $0.append(command) } 175 + } 176 + $0.worktreeInfoWatcher.send = { _ in } 177 + } 178 + store.exhaustivity = .off 179 + 180 + await store.send(.repositories(.delegate(.repositoriesChanged([repository])))) 181 + await store.finish() 182 + 183 + #expect( 184 + sentCommands.value.contains { 185 + if case .restoreLayoutSnapshot = $0 { 186 + return true 187 + } 188 + return false 189 + } == false 190 + ) 191 + } 192 + 161 193 @Test(.dependencies) func layoutRestoredEventSelectsWorktree() async { 162 194 let store = TestStore(initialState: AppFeature.State()) { 163 195 AppFeature()
+513 -62
supacodeTests/OpenCommandHandlerTests.swift
··· 8 8 9 9 struct OpenCommandHandlerTests { 10 10 11 + @MainActor 12 + final class MutableBox<Value>: @unchecked Sendable { 13 + var value: Value 14 + 15 + init(_ value: Value) { 16 + self.value = value 17 + } 18 + } 19 + 11 20 // MARK: - Helpers 12 21 22 + private func makeResolvedTarget( 23 + worktreeID: String = "Prowl:/Users/test/Projects/Prowl", 24 + worktreeName: String = "Prowl", 25 + worktreePath: String = "/Users/test/Projects/Prowl", 26 + worktreeRootPath: String = "/Users/test/Projects/Prowl", 27 + worktreeKind: String = "git", 28 + tabID: String = "0E2A7C03-9C01-4BC1-9327-6C1C7B629A52", 29 + tabTitle: String = "Prowl 1", 30 + tabCWD: String? = "/Users/test/Projects/Prowl", 31 + paneID: String = "0FB4DDB4-A797-4315-A00E-8AAFB32BFC95", 32 + paneTitle: String = "Prowl", 33 + paneCWD: String? = "/Users/test/Projects/Prowl" 34 + ) -> OpenResolvedTarget { 35 + OpenResolvedTarget( 36 + worktreeID: worktreeID, 37 + worktreeName: worktreeName, 38 + worktreePath: worktreePath, 39 + worktreeRootPath: worktreeRootPath, 40 + worktreeKind: worktreeKind, 41 + tabID: tabID, 42 + tabTitle: tabTitle, 43 + tabCWD: tabCWD, 44 + paneID: paneID, 45 + paneTitle: paneTitle, 46 + paneCWD: paneCWD 47 + ) 48 + } 49 + 13 50 private func makeHandler( 14 51 resolver: @escaping OpenCommandHandler.Resolver = { _ in 15 52 OpenResolverResult( ··· 20 57 selectWorktree: @escaping OpenCommandHandler.SelectAction = { _ in }, 21 58 addAndOpen: @escaping OpenCommandHandler.AddAndOpenAction = { _ in }, 22 59 createTabAtPath: @escaping OpenCommandHandler.CreateTabAtPathAction = { _, _ in }, 23 - terminalSnapshot: @escaping OpenCommandHandler.TerminalSnapshotProvider = { _ in nil } 60 + resolveTarget: @escaping OpenCommandHandler.ResolveTargetAction = { selector in 61 + switch selector { 62 + case .none: 63 + return OpenResolvedTarget( 64 + worktreeID: "Prowl:/Users/test/Projects/Prowl", 65 + worktreeName: "Prowl", 66 + worktreePath: "/Users/test/Projects/Prowl", 67 + worktreeRootPath: "/Users/test/Projects/Prowl", 68 + worktreeKind: "git", 69 + tabID: "0E2A7C03-9C01-4BC1-9327-6C1C7B629A52", 70 + tabTitle: "Prowl 1", 71 + tabCWD: "/Users/test/Projects/Prowl", 72 + paneID: "0FB4DDB4-A797-4315-A00E-8AAFB32BFC95", 73 + paneTitle: "Prowl", 74 + paneCWD: "/Users/test/Projects/Prowl" 75 + ) 76 + case .worktree(let value): 77 + return OpenResolvedTarget( 78 + worktreeID: value, 79 + worktreeName: "Prowl", 80 + worktreePath: "/Users/test/Projects/Prowl", 81 + worktreeRootPath: "/Users/test/Projects/Prowl", 82 + worktreeKind: "git", 83 + tabID: "0E2A7C03-9C01-4BC1-9327-6C1C7B629A52", 84 + tabTitle: "Prowl 1", 85 + tabCWD: "/Users/test/Projects/Prowl", 86 + paneID: "0FB4DDB4-A797-4315-A00E-8AAFB32BFC95", 87 + paneTitle: "Prowl", 88 + paneCWD: "/Users/test/Projects/Prowl" 89 + ) 90 + default: 91 + return nil 92 + } 93 + }, 94 + isRepositoriesReady: @escaping OpenCommandHandler.ReadinessProvider = { true }, 95 + sleep: @escaping OpenCommandHandler.SleepAction = { _ in }, 96 + waitTimeoutNanoseconds: UInt64 = 1_000_000 24 97 ) -> OpenCommandHandler { 25 98 OpenCommandHandler( 26 99 resolver: resolver, 27 100 selectWorktree: selectWorktree, 28 101 addAndOpen: addAndOpen, 29 102 createTabAtPath: createTabAtPath, 30 - terminalSnapshot: terminalSnapshot 103 + resolveTarget: resolveTarget, 104 + isRepositoriesReady: isRepositoriesReady, 105 + sleep: sleep, 106 + waitTimeoutNanoseconds: waitTimeoutNanoseconds, 107 + pollIntervalNanoseconds: 1 31 108 ) 32 109 } 33 110 111 + private func jsonObject(from response: CommandResponse) throws -> [String: Any] { 112 + let data = try #require(response.data) 113 + return try #require(JSONSerialization.jsonObject(with: data.bytes) as? [String: Any]) 114 + } 115 + 34 116 // MARK: - Bring to front (no path) 35 117 36 118 @MainActor 37 - @Test func openWithNoPathReturnsBringToFront() async throws { 119 + @Test func openWithNoPathReturnsBringToFrontAndCurrentTarget() async throws { 38 120 let handler = makeHandler() 39 121 let envelope = CommandEnvelope(output: .json, command: .open(OpenInput())) 40 122 let response = await handler.handle(envelope: envelope) ··· 45 127 46 128 let data = try #require(response.data) 47 129 let payload = try data.decode(as: OpenCommandData.self) 130 + let target = try #require(payload.target) 48 131 #expect(payload.resolution == "no-argument") 49 132 #expect(payload.invocation == "bare") 50 133 #expect(payload.requestedPath == nil) 51 134 #expect(payload.resolvedPath == nil) 52 135 #expect(payload.broughtToFront == true) 53 136 #expect(payload.appLaunched == false) 54 - #expect(payload.target == nil) 137 + #expect(target.worktree.id == "Prowl:/Users/test/Projects/Prowl") 138 + 139 + let json = try jsonObject(from: response) 140 + #expect(json.keys.contains("requested_path")) 141 + #expect(json.keys.contains("resolved_path")) 142 + #expect(json["requested_path"] is NSNull) 143 + #expect(json["resolved_path"] is NSNull) 144 + #expect((json["target"] as? [String: Any]) != nil) 145 + } 146 + 147 + @MainActor 148 + @Test func openWithNoPathSucceedsEvenWhenNoFocusedSurfaceCanBeResolved() async throws { 149 + let handler = makeHandler( 150 + resolveTarget: { _ in nil } 151 + ) 152 + 153 + let envelope = CommandEnvelope(output: .json, command: .open(OpenInput())) 154 + let response = await handler.handle(envelope: envelope) 155 + 156 + #expect(response.ok == true) 157 + 158 + let json = try jsonObject(from: response) 159 + #expect(json["resolution"] as? String == "no-argument") 160 + #expect(json["created_tab"] as? Bool == false) 161 + #expect(json.keys.contains("target")) 162 + #expect(json["target"] is NSNull) 163 + } 164 + 165 + // MARK: - No-argument regression (no spurious polling) 166 + 167 + @MainActor 168 + @Test func openWithNoPathDoesNotPollWhenNoTargetExists() async throws { 169 + let sleepCount = MutableBox(0) 170 + 171 + let handler = makeHandler( 172 + resolveTarget: { _ in nil }, 173 + sleep: { _ in 174 + await MainActor.run { sleepCount.value += 1 } 175 + }, 176 + waitTimeoutNanoseconds: 10_000_000_000 177 + ) 178 + 179 + let envelope = CommandEnvelope(output: .json, command: .open(OpenInput())) 180 + let response = await handler.handle(envelope: envelope) 181 + 182 + #expect(response.ok == true) 183 + #expect(sleepCount.value == 0, "Bare prowl must not poll when no target exists") 55 184 } 56 185 57 186 // MARK: - Exact root ··· 73 202 ) 74 203 }, 75 204 selectWorktree: { id in selectedID = id }, 76 - terminalSnapshot: { _ in 77 - OpenTerminalSnapshot( 78 - tabID: "0E2A7C03-9C01-4BC1-9327-6C1C7B629A52", 79 - tabTitle: "Prowl 1", 80 - tabCwd: "/Users/test/Projects/Prowl", 81 - paneID: "0FB4DDB4-A797-4315-A00E-8AAFB32BFC95", 82 - paneTitle: "Prowl", 83 - paneCwd: "/Users/test/Projects/Prowl" 84 - ) 205 + resolveTarget: { selector in 206 + guard case .worktree(let value) = selector else { return nil } 207 + return makeResolvedTarget(worktreeID: value) 85 208 } 86 209 ) 87 210 ··· 94 217 #expect(response.ok == true) 95 218 #expect(selectedID == "Prowl:/Users/test/Projects/Prowl") 96 219 97 - let data = try #require(response.data) 98 - let payload = try data.decode(as: OpenCommandData.self) 220 + let exactRootData = try #require(response.data) 221 + let payload = try exactRootData.decode(as: OpenCommandData.self) 222 + let target = try #require(payload.target) 99 223 #expect(payload.resolution == "exact-root") 100 224 #expect(payload.invocation == "open-subcommand") 101 225 #expect(payload.requestedPath == "/Users/test/Projects/Prowl") 102 226 #expect(payload.resolvedPath == "/Users/test/Projects/Prowl") 103 227 #expect(payload.createdTab == false) 104 228 #expect(payload.broughtToFront == true) 105 - 106 - let target = try #require(payload.target) 107 229 #expect(target.worktree.id == "Prowl:/Users/test/Projects/Prowl") 108 - #expect(target.worktree.name == "Prowl") 109 - #expect(target.worktree.kind == "git") 110 - #expect(target.tab?.id == "0E2A7C03-9C01-4BC1-9327-6C1C7B629A52") 111 - #expect(target.pane?.id == "0FB4DDB4-A797-4315-A00E-8AAFB32BFC95") 230 + #expect(target.tab.id == "0E2A7C03-9C01-4BC1-9327-6C1C7B629A52") 231 + #expect(target.pane.id == "0FB4DDB4-A797-4315-A00E-8AAFB32BFC95") 232 + 233 + let json = try jsonObject(from: response) 234 + #expect(Set(json.keys) == [ 235 + "invocation", "requested_path", "resolved_path", "resolution", 236 + "app_launched", "brought_to_front", "created_tab", "target", 237 + ]) 238 + let jsonTarget = try #require(json["target"] as? [String: Any]) 239 + #expect(Set(jsonTarget.keys) == ["worktree", "tab", "pane"]) 240 + let worktree = try #require(jsonTarget["worktree"] as? [String: Any]) 241 + #expect(Set(worktree.keys) == ["id", "name", "path", "root_path", "kind"]) 242 + let tab = try #require(jsonTarget["tab"] as? [String: Any]) 243 + #expect(Set(tab.keys) == ["id", "title", "cwd"]) 244 + let pane = try #require(jsonTarget["pane"] as? [String: Any]) 245 + #expect(Set(pane.keys) == ["id", "title", "cwd"]) 246 + } 247 + 248 + @MainActor 249 + @Test func openExactRootCreatesNewTabWhenSelectedWorktreeHasNoVisibleSurface() async throws { 250 + var selectedID: String? 251 + var createdTabFor: String? 252 + var createdTabAtPath: String? 253 + let created = MutableBox(false) 254 + let rootPath = "/Users/test/Projects/Prowl" 255 + 256 + let handler = makeHandler( 257 + resolver: { _ in 258 + OpenResolverResult( 259 + resolution: .exactRoot, 260 + worktreeID: "Prowl:/Users/test/Projects/Prowl", 261 + worktreeName: "Prowl", 262 + worktreePath: rootPath, 263 + rootPath: rootPath, 264 + worktreeKind: "git", 265 + resolvedPath: rootPath 266 + ) 267 + }, 268 + selectWorktree: { selectedID = $0 }, 269 + createTabAtPath: { worktreeID, path in 270 + createdTabFor = worktreeID 271 + createdTabAtPath = path 272 + created.value = true 273 + }, 274 + resolveTarget: { selector in 275 + guard created.value, case .worktree(let value) = selector else { return nil } 276 + return makeResolvedTarget(worktreeID: value, tabCWD: rootPath, paneCWD: rootPath) 277 + }, 278 + waitTimeoutNanoseconds: 3 279 + ) 280 + 281 + let envelope = CommandEnvelope( 282 + output: .json, 283 + command: .open(OpenInput(path: rootPath, invocation: "open-subcommand")) 284 + ) 285 + let response = await handler.handle(envelope: envelope) 286 + 287 + #expect(response.ok == true) 288 + #expect(selectedID == "Prowl:/Users/test/Projects/Prowl") 289 + #expect(createdTabFor == "Prowl:/Users/test/Projects/Prowl") 290 + #expect(createdTabAtPath == rootPath) 291 + 292 + let json = try jsonObject(from: response) 293 + #expect(json["resolution"] as? String == "exact-root") 294 + #expect(json["created_tab"] as? Bool == true) 295 + let target = try #require(json["target"] as? [String: Any]) 296 + let pane = try #require(target["pane"] as? [String: Any]) 297 + #expect(pane["cwd"] as? String == rootPath) 298 + } 299 + 300 + @MainActor 301 + @Test func openExactRootDoesNotPollBeforeCreatingTab() async throws { 302 + let sleepBeforeCreate = MutableBox(0) 303 + let created = MutableBox(false) 304 + let rootPath = "/Users/test/Projects/Prowl" 305 + 306 + let handler = makeHandler( 307 + resolver: { _ in 308 + OpenResolverResult( 309 + resolution: .exactRoot, 310 + worktreeID: "Prowl:/Users/test/Projects/Prowl", 311 + worktreeName: "Prowl", 312 + worktreePath: rootPath, 313 + rootPath: rootPath, 314 + worktreeKind: "git", 315 + resolvedPath: rootPath 316 + ) 317 + }, 318 + createTabAtPath: { _, _ in 319 + created.value = true 320 + }, 321 + resolveTarget: { selector in 322 + guard created.value, case .worktree(let value) = selector else { return nil } 323 + return makeResolvedTarget(worktreeID: value, tabCWD: rootPath, paneCWD: rootPath) 324 + }, 325 + sleep: { _ in 326 + await MainActor.run { 327 + if !created.value { 328 + sleepBeforeCreate.value += 1 329 + } 330 + } 331 + }, 332 + waitTimeoutNanoseconds: 3 333 + ) 334 + 335 + let envelope = CommandEnvelope( 336 + output: .json, 337 + command: .open(OpenInput(path: rootPath, invocation: "open-subcommand")) 338 + ) 339 + let response = await handler.handle(envelope: envelope) 340 + 341 + #expect(response.ok == true) 342 + #expect(sleepBeforeCreate.value == 0, "Must not poll before creating tab when worktree has no visible surface") 112 343 } 113 344 114 345 // MARK: - Inside root 115 346 116 347 @MainActor 117 - @Test func openInsideRootSelectsWorktreeAndCreatesTab() async throws { 348 + @Test func openInsideRootSelectsWorktreeCreatesTabAndKeepsExactTargetCwd() async throws { 118 349 var selectedID: String? 119 350 var tabCreatedForWorktree: String? 120 351 var tabCreatedAtPath: String? 121 352 353 + let subpath = "/Users/test/Projects/Prowl/supacode" 354 + 122 355 let handler = makeHandler( 123 356 resolver: { _ in 124 357 OpenResolverResult( ··· 128 361 worktreePath: "/Users/test/Projects/Prowl", 129 362 rootPath: "/Users/test/Projects/Prowl", 130 363 worktreeKind: "git", 131 - resolvedPath: "/Users/test/Projects/Prowl/supacode" 364 + resolvedPath: subpath 132 365 ) 133 366 }, 134 367 selectWorktree: { id in selectedID = id }, 135 368 createTabAtPath: { worktreeID, path in 136 369 tabCreatedForWorktree = worktreeID 137 370 tabCreatedAtPath = path 371 + }, 372 + resolveTarget: { selector in 373 + guard case .worktree(let value) = selector else { return nil } 374 + return makeResolvedTarget(worktreeID: value, tabCWD: subpath, paneCWD: subpath) 138 375 } 139 376 ) 140 377 141 378 let envelope = CommandEnvelope( 142 379 output: .json, 143 - command: .open(OpenInput(path: "/Users/test/Projects/Prowl/supacode", invocation: "implicit-open")) 380 + command: .open(OpenInput(path: subpath, invocation: "implicit-open")) 144 381 ) 145 382 let response = await handler.handle(envelope: envelope) 146 383 147 384 #expect(response.ok == true) 148 385 #expect(selectedID == "Prowl:/Users/test/Projects/Prowl") 149 386 #expect(tabCreatedForWorktree == "Prowl:/Users/test/Projects/Prowl") 150 - #expect(tabCreatedAtPath == "/Users/test/Projects/Prowl/supacode") 387 + #expect(tabCreatedAtPath == subpath) 151 388 152 - let data = try #require(response.data) 153 - let payload = try data.decode(as: OpenCommandData.self) 389 + let insideRootData = try #require(response.data) 390 + let payload = try insideRootData.decode(as: OpenCommandData.self) 391 + let target = try #require(payload.target) 154 392 #expect(payload.resolution == "inside-root") 155 393 #expect(payload.invocation == "implicit-open") 156 - #expect(payload.requestedPath == "/Users/test/Projects/Prowl/supacode") 157 - #expect(payload.resolvedPath == "/Users/test/Projects/Prowl/supacode") 394 + #expect(payload.requestedPath == subpath) 395 + #expect(payload.resolvedPath == subpath) 158 396 #expect(payload.createdTab == true) 397 + #expect(target.tab.cwd == subpath) 398 + #expect(target.pane.cwd == subpath) 159 399 } 160 400 161 401 // MARK: - New root 162 402 163 403 @MainActor 164 - @Test func openNewRootCallsAddAndOpen() async throws { 404 + @Test func openNewRootCallsAddAndWaitsForManagedTarget() async throws { 165 405 var addedURL: URL? 406 + let managed = MutableBox(false) 166 407 167 408 let handler = makeHandler( 168 409 resolver: { _ in 169 - OpenResolverResult( 410 + if managed.value { 411 + return OpenResolverResult( 412 + resolution: .exactRoot, 413 + worktreeID: "NewProject:/Users/test/NewProject", 414 + worktreeName: "NewProject", 415 + worktreePath: "/Users/test/NewProject", 416 + rootPath: "/Users/test/NewProject", 417 + worktreeKind: "git", 418 + resolvedPath: "/Users/test/NewProject" 419 + ) 420 + } 421 + return OpenResolverResult( 170 422 resolution: .newRoot, 171 - worktreeID: nil, worktreeName: nil, 172 - worktreePath: nil, rootPath: nil, 173 - worktreeKind: nil, resolvedPath: "/Users/test/NewProject" 423 + worktreeID: nil, 424 + worktreeName: nil, 425 + worktreePath: nil, 426 + rootPath: nil, 427 + worktreeKind: nil, 428 + resolvedPath: "/Users/test/NewProject" 174 429 ) 175 430 }, 176 - addAndOpen: { url in addedURL = url } 431 + addAndOpen: { url in addedURL = url }, 432 + resolveTarget: { selector in 433 + guard managed.value, case .worktree(let value) = selector else { return nil } 434 + return makeResolvedTarget( 435 + worktreeID: value, 436 + worktreeName: "NewProject", 437 + worktreePath: "/Users/test/NewProject", 438 + worktreeRootPath: "/Users/test/NewProject", 439 + tabCWD: "/Users/test/NewProject", 440 + paneCWD: "/Users/test/NewProject" 441 + ) 442 + }, 443 + sleep: { _ in 444 + await MainActor.run { 445 + managed.value = true 446 + } 447 + }, 448 + waitTimeoutNanoseconds: 3 177 449 ) 178 450 179 451 let envelope = CommandEnvelope( ··· 185 457 #expect(response.ok == true) 186 458 #expect(addedURL?.path == "/Users/test/NewProject") 187 459 188 - let data = try #require(response.data) 189 - let payload = try data.decode(as: OpenCommandData.self) 460 + let newRootData = try #require(response.data) 461 + let payload = try newRootData.decode(as: OpenCommandData.self) 462 + let target = try #require(payload.target) 190 463 #expect(payload.resolution == "new-root") 191 464 #expect(payload.requestedPath == "/Users/test/NewProject") 192 465 #expect(payload.createdTab == true) 193 - #expect(payload.target == nil) 466 + #expect(target.worktree.id == "NewProject:/Users/test/NewProject") 467 + } 468 + 469 + @MainActor 470 + @Test func openNewRootCreatesTabWhenRepositoryBecomesManagedWithoutVisibleSurface() async throws { 471 + var addedURL: URL? 472 + var createdTabFor: String? 473 + var createdTabAtPath: String? 474 + let managed = MutableBox(false) 475 + let created = MutableBox(false) 476 + let rootPath = "/Users/test/NewProject" 477 + let worktreeID = "NewProject:/Users/test/NewProject" 478 + 479 + let handler = makeHandler( 480 + resolver: { _ in 481 + if managed.value { 482 + return OpenResolverResult( 483 + resolution: .exactRoot, 484 + worktreeID: worktreeID, 485 + worktreeName: "NewProject", 486 + worktreePath: rootPath, 487 + rootPath: rootPath, 488 + worktreeKind: "git", 489 + resolvedPath: rootPath 490 + ) 491 + } 492 + return OpenResolverResult( 493 + resolution: .newRoot, 494 + worktreeID: nil, 495 + worktreeName: nil, 496 + worktreePath: nil, 497 + rootPath: nil, 498 + worktreeKind: nil, 499 + resolvedPath: rootPath 500 + ) 501 + }, 502 + addAndOpen: { url in addedURL = url }, 503 + createTabAtPath: { id, path in 504 + createdTabFor = id 505 + createdTabAtPath = path 506 + created.value = true 507 + }, 508 + resolveTarget: { selector in 509 + guard created.value, case .worktree(let value) = selector else { return nil } 510 + return makeResolvedTarget( 511 + worktreeID: value, 512 + worktreeName: "NewProject", 513 + worktreePath: rootPath, 514 + worktreeRootPath: rootPath, 515 + tabCWD: rootPath, 516 + paneCWD: rootPath 517 + ) 518 + }, 519 + sleep: { _ in 520 + await MainActor.run { 521 + managed.value = true 522 + } 523 + }, 524 + waitTimeoutNanoseconds: 3 525 + ) 526 + 527 + let envelope = CommandEnvelope( 528 + output: .json, 529 + command: .open(OpenInput(path: rootPath, invocation: "implicit-open")) 530 + ) 531 + let response = await handler.handle(envelope: envelope) 532 + 533 + #expect(response.ok == true) 534 + #expect( 535 + addedURL?.standardizedFileURL.path(percentEncoded: false) 536 + == URL(fileURLWithPath: rootPath, isDirectory: true).standardizedFileURL.path(percentEncoded: false) 537 + ) 538 + #expect(createdTabFor == worktreeID) 539 + #expect(createdTabAtPath == rootPath) 540 + 541 + let json = try jsonObject(from: response) 542 + #expect(json["resolution"] as? String == "new-root") 543 + #expect(json["created_tab"] as? Bool == true) 544 + let target = try #require(json["target"] as? [String: Any]) 545 + let pane = try #require(target["pane"] as? [String: Any]) 546 + #expect(pane["cwd"] as? String == rootPath) 547 + } 548 + 549 + // MARK: - Readiness gating 550 + 551 + @MainActor 552 + @Test func waitsForRepositoriesReadyBeforeResolvingColdLaunchPath() async throws { 553 + let ready = MutableBox(false) 554 + var selectedID: String? 555 + var addAndOpenCalled = false 556 + 557 + let handler = makeHandler( 558 + resolver: { _ in 559 + if ready.value { 560 + return OpenResolverResult( 561 + resolution: .exactRoot, 562 + worktreeID: "Prowl:/Users/test/Projects/Prowl", 563 + worktreeName: "Prowl", 564 + worktreePath: "/Users/test/Projects/Prowl", 565 + rootPath: "/Users/test/Projects/Prowl", 566 + worktreeKind: "git", 567 + resolvedPath: "/Users/test/Projects/Prowl" 568 + ) 569 + } 570 + return OpenResolverResult( 571 + resolution: .newRoot, 572 + worktreeID: nil, 573 + worktreeName: nil, 574 + worktreePath: nil, 575 + rootPath: nil, 576 + worktreeKind: nil, 577 + resolvedPath: "/Users/test/Projects/Prowl" 578 + ) 579 + }, 580 + selectWorktree: { selectedID = $0 }, 581 + addAndOpen: { _ in addAndOpenCalled = true }, 582 + resolveTarget: { selector in 583 + guard ready.value, case .worktree(let value) = selector else { return nil } 584 + return makeResolvedTarget(worktreeID: value) 585 + }, 586 + isRepositoriesReady: { ready.value }, 587 + sleep: { _ in 588 + await MainActor.run { 589 + ready.value = true 590 + } 591 + }, 592 + waitTimeoutNanoseconds: 10_000 593 + ) 594 + 595 + let envelope = CommandEnvelope( 596 + output: .json, 597 + command: .open(OpenInput(path: "/Users/test/Projects/Prowl", appLaunched: true)) 598 + ) 599 + let response = await handler.handle(envelope: envelope) 600 + 601 + #expect(response.ok == true) 602 + #expect(addAndOpenCalled == false) 603 + #expect(selectedID == "Prowl:/Users/test/Projects/Prowl") 604 + 605 + let readinessData = try #require(response.data) 606 + let payload = try readinessData.decode(as: OpenCommandData.self) 607 + #expect(payload.resolution == "exact-root") 608 + #expect(payload.appLaunched == true) 194 609 } 195 610 196 611 // MARK: - Router integration ··· 222 637 // MARK: - Invocation derivation 223 638 224 639 @MainActor 225 - @Test func defaultInvocationIsBareWhenNoPath() async throws { 226 - let handler = makeHandler() 227 - let envelope = CommandEnvelope(output: .json, command: .open(OpenInput())) 228 - let response = await handler.handle(envelope: envelope) 229 - let data = try #require(response.data) 230 - let payload = try data.decode(as: OpenCommandData.self) 231 - #expect(payload.invocation == "bare") 232 - } 233 - 234 - @MainActor 235 640 @Test func defaultInvocationIsOpenSubcommandWhenPathPresent() async throws { 641 + let managed = MutableBox(false) 236 642 let handler = makeHandler( 237 643 resolver: { _ in 238 - OpenResolverResult( 644 + if managed.value { 645 + return OpenResolverResult( 646 + resolution: .exactRoot, 647 + worktreeID: "test:/tmp/test", 648 + worktreeName: "test", 649 + worktreePath: "/tmp/test", 650 + rootPath: "/tmp/test", 651 + worktreeKind: "git", 652 + resolvedPath: "/tmp/test" 653 + ) 654 + } 655 + return OpenResolverResult( 239 656 resolution: .newRoot, 240 - worktreeID: nil, worktreeName: nil, 241 - worktreePath: nil, rootPath: nil, 242 - worktreeKind: nil, resolvedPath: "/tmp/test" 657 + worktreeID: nil, 658 + worktreeName: nil, 659 + worktreePath: nil, 660 + rootPath: nil, 661 + worktreeKind: nil, 662 + resolvedPath: "/tmp/test" 243 663 ) 244 - } 664 + }, 665 + resolveTarget: { selector in 666 + guard managed.value, case .worktree(let value) = selector else { return nil } 667 + return makeResolvedTarget( 668 + worktreeID: value, 669 + worktreeName: "test", 670 + worktreePath: "/tmp/test", 671 + worktreeRootPath: "/tmp/test", 672 + tabCWD: "/tmp/test", 673 + paneCWD: "/tmp/test" 674 + ) 675 + }, 676 + sleep: { _ in 677 + await MainActor.run { 678 + managed.value = true 679 + } 680 + }, 681 + waitTimeoutNanoseconds: 3 245 682 ) 246 683 let envelope = CommandEnvelope(output: .json, command: .open(OpenInput(path: "/tmp/test"))) 247 684 let response = await handler.handle(envelope: envelope) 248 - let data = try #require(response.data) 249 - let payload = try data.decode(as: OpenCommandData.self) 685 + let invocationData = try #require(response.data) 686 + let payload = try invocationData.decode(as: OpenCommandData.self) 250 687 #expect(payload.invocation == "open-subcommand") 251 688 } 252 689 ··· 255 692 let handler = makeHandler( 256 693 resolver: { _ in 257 694 OpenResolverResult( 258 - resolution: .newRoot, 259 - worktreeID: nil, worktreeName: nil, 260 - worktreePath: nil, rootPath: nil, 261 - worktreeKind: nil, resolvedPath: "/tmp/test" 695 + resolution: .insideRoot, 696 + worktreeID: "Prowl:/tmp/test", 697 + worktreeName: "test", 698 + worktreePath: "/tmp/test", 699 + rootPath: "/tmp/test", 700 + worktreeKind: "git", 701 + resolvedPath: "/tmp/test/subdir" 702 + ) 703 + }, 704 + resolveTarget: { selector in 705 + guard case .worktree(let value) = selector else { return nil } 706 + return makeResolvedTarget( 707 + worktreeID: value, 708 + worktreeName: "test", 709 + worktreePath: "/tmp/test", 710 + worktreeRootPath: "/tmp/test", 711 + tabCWD: "/tmp/test/subdir", 712 + paneCWD: "/tmp/test/subdir" 262 713 ) 263 714 } 264 715 ) 265 716 let envelope = CommandEnvelope( 266 717 output: .json, 267 - command: .open(OpenInput(path: "/tmp/test", invocation: "implicit-open")) 718 + command: .open(OpenInput(path: "/tmp/test/subdir", invocation: "implicit-open")) 268 719 ) 269 720 let response = await handler.handle(envelope: envelope) 270 - let data = try #require(response.data) 271 - let payload = try data.decode(as: OpenCommandData.self) 721 + let explicitInvocationData = try #require(response.data) 722 + let payload = try explicitInvocationData.decode(as: OpenCommandData.self) 272 723 #expect(payload.invocation == "implicit-open") 273 724 } 274 725 }
+67
supacodeTests/RepositoriesFeatureTests.swift
··· 3323 3323 await store.finish() 3324 3324 } 3325 3325 3326 + @Test func cliOpenLaunchModeSkipsLastFocusedRestoreSelection() async { 3327 + let testID = UUID().uuidString 3328 + let repoRootA = "/tmp/\(testID)-repo-a" 3329 + let repoRootB = "/tmp/\(testID)-repo-b" 3330 + let worktreeA = makeWorktree(id: "\(repoRootA)/main", name: "main", repoRoot: repoRootA) 3331 + let worktreeB = makeWorktree(id: "\(repoRootB)/main", name: "main", repoRoot: repoRootB) 3332 + let repoA = makeRepository( 3333 + id: repoRootA, 3334 + name: URL(fileURLWithPath: repoRootA).lastPathComponent, 3335 + worktrees: [worktreeA] 3336 + ) 3337 + let repoB = makeRepository( 3338 + id: repoRootB, 3339 + name: URL(fileURLWithPath: repoRootB).lastPathComponent, 3340 + worktrees: [worktreeB] 3341 + ) 3342 + 3343 + var state = RepositoriesFeature.State() 3344 + state.launchRestoreMode = .cliOpenPath(repoRootA) 3345 + 3346 + let store = TestStore(initialState: state) { 3347 + RepositoriesFeature() 3348 + } withDependencies: { 3349 + $0.repositoryPersistence.loadLastFocusedWorktreeID = { worktreeB.id } 3350 + $0.repositoryPersistence.loadRepositorySnapshot = { [repoA, repoB] } 3351 + $0.repositoryPersistence.loadRoots = { [repoRootA, repoRootB] } 3352 + $0.repositoryPersistence.saveRepositorySnapshot = { _ in } 3353 + $0.gitClient.worktrees = { root in 3354 + switch root.path(percentEncoded: false) { 3355 + case repoRootA: 3356 + return [worktreeA] 3357 + case repoRootB: 3358 + return [worktreeB] 3359 + default: 3360 + return [] 3361 + } 3362 + } 3363 + } 3364 + 3365 + await store.send(.task) { 3366 + $0.snapshotPersistencePhase = .restoring 3367 + } 3368 + await store.receive(\.pinnedWorktreeIDsLoaded) 3369 + await store.receive(\.archivedWorktreeIDsLoaded) 3370 + await store.receive(\.repositoryOrderIDsLoaded) 3371 + await store.receive(\.worktreeOrderByRepositoryLoaded) 3372 + await store.receive(\.lastFocusedWorktreeIDLoaded) { 3373 + $0.lastFocusedWorktreeID = worktreeB.id 3374 + $0.shouldRestoreLastFocusedWorktree = false 3375 + } 3376 + await store.receive(\.repositorySnapshotLoaded) { 3377 + $0.repositories = [repoA, repoB] 3378 + $0.repositoryRoots = [repoRootA, repoRootB].map { URL(fileURLWithPath: $0) } 3379 + $0.selection = nil 3380 + $0.isInitialLoadComplete = true 3381 + } 3382 + await store.receive(\.delegate.repositoriesChanged) 3383 + await store.receive(\.loadPersistedRepositories) 3384 + await store.receive(\.repositoriesLoaded) { 3385 + $0.snapshotPersistencePhase = .active 3386 + $0.selection = nil 3387 + $0.shouldRestoreLastFocusedWorktree = false 3388 + } 3389 + await store.receive(\.delegate.repositoriesChanged) 3390 + await store.finish() 3391 + } 3392 + 3326 3393 private actor AsyncGate { 3327 3394 var continuation: CheckedContinuation<Void, Never>? 3328 3395 var isOpen = false
+52
supacodeTests/SupacodeAppCLITests.swift
··· 1 + import ComposableArchitecture 2 + import Foundation 3 + import GhosttyKit 4 + import Testing 5 + 6 + @testable import supacode 7 + 8 + @MainActor 9 + struct SupacodeAppCLITests { 10 + @Test func cliRouterWiresKeyAndReadHandlersInsteadOfStubHandlers() async { 11 + let store = Store(initialState: AppFeature.State()) { 12 + AppFeature() 13 + } 14 + let terminalManager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 15 + let router = SupacodeApp.makeCLICommandRouter(appStore: store, terminalManager: terminalManager) 16 + 17 + let keyResponse = await router.route( 18 + CommandEnvelope(output: .json, command: .key(KeyInput(rawToken: "enter", token: "enter"))) 19 + ) 20 + let readResponse = await router.route( 21 + CommandEnvelope(output: .json, command: .read(ReadInput())) 22 + ) 23 + 24 + #expect(keyResponse.command == "key") 25 + #expect(readResponse.command == "read") 26 + #expect(keyResponse.error?.code != "NOT_IMPLEMENTED") 27 + #expect(readResponse.error?.code != "NOT_IMPLEMENTED") 28 + } 29 + 30 + @Test func resolveCLITerminalWorktreeBuildsSyntheticRunnableFolderWorktree() { 31 + let repository = Repository( 32 + id: "/Users/test/PlainFolder", 33 + rootURL: URL(fileURLWithPath: "/Users/test/PlainFolder", isDirectory: true), 34 + name: "PlainFolder", 35 + kind: .plain, 36 + worktrees: [] 37 + ) 38 + 39 + let resolved = SupacodeApp.resolveCLITerminalWorktree( 40 + id: repository.id, 41 + repositories: [repository] 42 + ) 43 + 44 + #expect(resolved?.id == repository.id) 45 + #expect(resolved?.name == "PlainFolder") 46 + #expect( 47 + resolved?.workingDirectory.standardizedFileURL.path(percentEncoded: false) 48 + == URL(fileURLWithPath: "/Users/test/PlainFolder", isDirectory: true) 49 + .standardizedFileURL.path(percentEncoded: false) 50 + ) 51 + } 52 + }