native macOS codings agent orchestrator
5
fork

Configure Feed

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

Add deeplinks (#223)

Introduce supacode:// URL scheme for controlling the app externally.
Supports worktree actions (select, run, stop, archive, pin, etc.),
tab/surface management (focus, create, split, destroy), repo operations
(open, create worktree), and settings navigation.

Includes a confirmation dialog for deeplinks carrying arbitrary input
or destructive actions, with an opt-out setting for power users.

authored by

Stefano Bertagno and committed by
GitHub
a7f6d81f 7384483e

+3033 -227
+20 -5
Makefile
··· 13 13 GHOSTTY_BUILD_OUTPUTS := $(GHOSTTY_XCFRAMEWORK_PATH) $(GHOSTTY_RESOURCE_PATH) $(GHOSTTY_TERMINFO_PATH) 14 14 PROJECT_FILE_PATH := supacode.xcodeproj/project.pbxproj 15 15 SPM_CACHE_DIR := /tmp/supacode-spm-cache/SourcePackages 16 + FORMAT ?= xcsift 16 17 VERSION ?= 17 18 BUILD ?= 18 19 XCODEBUILD_FLAGS ?= 20 + 21 + # Output formatter pipe. Usage: make build-app FORMAT=xcpretty|xcsift|none. 22 + ifeq ($(FORMAT),xcsift) 23 + FORMATTER = | mise exec -- xcsift -qw --format toon 24 + else ifeq ($(FORMAT),xcpretty) 25 + ifeq (,$(shell command -v xcpretty 2>/dev/null)) 26 + $(error xcpretty is not installed. Install it with: gem install xcpretty) 27 + endif 28 + FORMATTER = | xcpretty 29 + else ifeq ($(FORMAT),none) 30 + FORMATTER = 31 + else 32 + $(error Unknown FORMAT "$(FORMAT)". Use xcsift, xcpretty, or none) 33 + endif 19 34 .DEFAULT_GOAL := help 20 35 .PHONY: build-ghostty-xcframework build-app run-app install-dev-build archive export-archive format lint check test bump-version bump-and-release log-stream 21 36 ··· 39 54 rsync -a --delete "$$terminfo_src/" "$$terminfo_dst/" 40 55 41 56 build-app: build-ghostty-xcframework # Build the macOS app (Debug) 42 - bash -o pipefail -c 'xcodebuild -project supacode.xcodeproj -scheme supacode -configuration Debug build -skipMacroValidation -clonedSourcePackagesDirPath "$(SPM_CACHE_DIR)" 2>&1 | mise exec -- xcsift -qw --format toon' 57 + bash -o pipefail -c 'xcodebuild -project supacode.xcodeproj -scheme supacode -configuration Debug build -skipMacroValidation -clonedSourcePackagesDirPath "$(SPM_CACHE_DIR)" 2>&1 $(FORMATTER)' 43 58 44 59 run-app: build-app # Build then launch (Debug) with log streaming 45 60 @settings="$$(xcodebuild -project supacode.xcodeproj -scheme supacode -configuration Debug -showBuildSettings -json 2>/dev/null)"; \ ··· 64 79 echo "installed $$dst" 65 80 66 81 archive: build-ghostty-xcframework # Archive Release build for distribution 67 - bash -o pipefail -c 'xcodebuild -project supacode.xcodeproj -scheme supacode -configuration Release -archivePath build/supacode.xcarchive archive CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM="$$APPLE_TEAM_ID" CODE_SIGN_IDENTITY="$$DEVELOPER_ID_IDENTITY_SHA" OTHER_CODE_SIGN_FLAGS="--timestamp" -skipMacroValidation -clonedSourcePackagesDirPath "$(SPM_CACHE_DIR)" $(XCODEBUILD_FLAGS) 2>&1 | mise exec -- xcsift -qw --format toon' 82 + bash -o pipefail -c 'xcodebuild -project supacode.xcodeproj -scheme supacode -configuration Release -archivePath build/supacode.xcarchive archive CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM="$$APPLE_TEAM_ID" CODE_SIGN_IDENTITY="$$DEVELOPER_ID_IDENTITY_SHA" OTHER_CODE_SIGN_FLAGS="--timestamp" -skipMacroValidation -clonedSourcePackagesDirPath "$(SPM_CACHE_DIR)" $(XCODEBUILD_FLAGS) 2>&1 $(FORMATTER)' 68 83 69 84 export-archive: # Export xarchive 70 - bash -o pipefail -c 'xcodebuild -exportArchive -archivePath build/supacode.xcarchive -exportPath build/export -exportOptionsPlist build/ExportOptions.plist 2>&1 | mise exec -- xcsift -qw --format toon' 85 + bash -o pipefail -c 'xcodebuild -exportArchive -archivePath build/supacode.xcarchive -exportPath build/export -exportOptionsPlist build/ExportOptions.plist 2>&1 $(FORMATTER)' 71 86 72 - test: build-ghostty-xcframework 73 - xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation -parallel-testing-enabled NO -clonedSourcePackagesDirPath "$(SPM_CACHE_DIR)" 2>&1 87 + test: build-ghostty-xcframework # Run all tests 88 + bash -o pipefail -c 'xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation -parallel-testing-enabled NO -clonedSourcePackagesDirPath "$(SPM_CACHE_DIR)" 2>&1 $(FORMATTER)' 74 89 75 90 format: # Format code with swift-format (local only) 76 91 swift-format -p --in-place --recursive --configuration ./.swift-format.json supacode supacodeTests
+8 -2
supacode/App/ContentView.swift
··· 72 72 } 73 73 .alert(store: repositoriesStore.scope(state: \.$alert, action: \.alert)) 74 74 .alert(store: store.scope(state: \.$alert, action: \.alert)) 75 - .sheet(store: repositoriesStore.scope(state: \.$worktreeCreationPrompt, action: \.worktreeCreationPrompt)) { 76 - promptStore in 75 + .sheet( 76 + store: store.scope(state: \.$deeplinkInputConfirmation, action: \.deeplinkInputConfirmation) 77 + ) { confirmationStore in 78 + DeeplinkInputConfirmationView(store: confirmationStore) 79 + } 80 + .sheet( 81 + store: repositoriesStore.scope(state: \.$worktreeCreationPrompt, action: \.worktreeCreationPrompt) 82 + ) { promptStore in 77 83 WorktreeCreationPromptView(store: promptStore) 78 84 } 79 85 .sheet(isPresented: isRunScriptPromptPresented) {
+182
supacode/App/DeeplinkCheatsheetView.swift
··· 1 + import ComposableArchitecture 2 + import SwiftUI 3 + 4 + struct DeeplinkCheatsheetView: View { 5 + var body: some View { 6 + Form { 7 + Section { 8 + Text( 9 + // swiftlint:disable:next line_length 10 + "Each terminal session exposes \(code("SUPACODE_REPO_ID")), \(code("SUPACODE_WORKTREE_ID")), \(code("SUPACODE_TAB_ID")), and \(code("SUPACODE_SURFACE_ID")) as environment variables. Run \(code("env | grep SUPACODE_")) to discover the IDs for the current session." 11 + ) 12 + .foregroundStyle(.secondary) 13 + Text( 14 + // swiftlint:disable:next line_length 15 + "Worktree and repository IDs must be percent-encoded (e.g. `/tmp/repo` → `%2Ftmp%2Frepo`), and \(code("SUPACODE_REPO_ID")) and \(code("SUPACODE_WORKTREE_ID")) already are." 16 + ) 17 + .foregroundStyle(.secondary) 18 + Text( 19 + "Deeplinks that run commands or perform destructive actions require confirmation" 20 + + " unless \"Allow Arbitrary Deeplink Actions\" is enabled in Settings." 21 + ) 22 + .foregroundStyle(.secondary) 23 + } header: { 24 + Text("Deeplink Reference").font(.title.bold()) 25 + Text("Use the \(code("supacode://")) URL scheme to control Supacode from the terminal, scripts, or other apps.") 26 + } 27 + 28 + DeeplinkSection(title: "General", rows: Self.generalRows) 29 + DeeplinkSection(title: "Worktree Actions", rows: Self.worktreeRows) 30 + DeeplinkSection(title: "Tab & Surface", rows: Self.tabSurfaceRows) 31 + DeeplinkSection(title: "Repository", rows: Self.repoRows) 32 + DeeplinkSection(title: "Settings", rows: Self.settingsRows) 33 + } 34 + .textSelection(.enabled) 35 + .formStyle(.grouped) 36 + .frame(minWidth: 300) 37 + .navigationTitle("") 38 + } 39 + 40 + // MARK: - Row data. 41 + 42 + private static let generalRows: [DeeplinkEntry] = [ 43 + .init(url: "supacode://", description: "Bring app to front."), 44 + .init(url: "supacode://help", description: "Open this reference."), 45 + ] 46 + 47 + private static let worktreeRows: [DeeplinkEntry] = [ 48 + .init(url: "supacode://worktree/<worktree_id>", description: "Select worktree."), 49 + .init(url: "supacode://worktree/<worktree_id>/run", description: "Run the worktree script."), 50 + .init(url: "supacode://worktree/<worktree_id>/stop", description: "Stop the running script."), 51 + .init(url: "supacode://worktree/<worktree_id>/archive", description: "Archive the worktree."), 52 + .init(url: "supacode://worktree/<worktree_id>/unarchive", description: "Unarchive the worktree."), 53 + .init(url: "supacode://worktree/<worktree_id>/delete", description: "Delete the worktree."), 54 + .init(url: "supacode://worktree/<worktree_id>/pin", description: "Pin the worktree."), 55 + .init(url: "supacode://worktree/<worktree_id>/unpin", description: "Unpin the worktree."), 56 + ] 57 + 58 + private static let tabSurfaceRows: [DeeplinkEntry] = [ 59 + .init( 60 + url: "supacode://worktree/<worktree_id>/tab/<tab_id>", 61 + description: "Focus a tab." 62 + ), 63 + .init( 64 + url: "supacode://worktree/<worktree_id>/tab/new", 65 + description: "Create a new tab.", 66 + params: "?input=<cmd>&id=<uuid>" 67 + ), 68 + .init( 69 + url: "supacode://worktree/<worktree_id>/tab/<tab_id>/destroy", 70 + description: "Close a tab." 71 + ), 72 + .init( 73 + url: "supacode://worktree/<worktree_id>/tab/<tab_id>/surface/<surface_id>", 74 + description: "Focus a surface.", 75 + params: "?input=<cmd>" 76 + ), 77 + .init( 78 + url: "supacode://worktree/<worktree_id>/tab/<tab_id>/surface/<surface_id>/split", 79 + description: "Split a surface. Defaults to horizontal.", 80 + params: "?direction=horizontal|vertical&input=<cmd>&id=<uuid>" 81 + ), 82 + .init( 83 + url: "supacode://worktree/<worktree_id>/tab/<tab_id>/surface/<surface_id>/destroy", 84 + description: "Close a surface." 85 + ), 86 + ] 87 + 88 + private static let repoRows: [DeeplinkEntry] = [ 89 + .init(url: "supacode://repo/open?path=<absolute-path>", description: "Open a repository."), 90 + .init( 91 + url: "supacode://repo/<repo_id>/worktree/new", 92 + description: "Create a worktree.", 93 + params: "?branch=<name>&base=<ref>&fetch=true" 94 + ), 95 + ] 96 + 97 + private static let settingsRows: [DeeplinkEntry] = [ 98 + .init(url: "supacode://settings", description: "Open settings."), 99 + .init( 100 + url: "supacode://settings/<section>", 101 + description: "Open a specific section.", 102 + params: "general|notifications|worktrees|codingAgents|shortcuts|updates|github" 103 + ), 104 + .init(url: "supacode://settings/repo/<repo_id>", description: "Open repository settings."), 105 + ] 106 + } 107 + 108 + // MARK: - Components. 109 + 110 + private struct DeeplinkEntry: Identifiable { 111 + let id = UUID() 112 + let url: String 113 + let description: String 114 + var params: String? 115 + } 116 + 117 + private struct DeeplinkSection: View { 118 + let title: String 119 + let rows: [DeeplinkEntry] 120 + 121 + var body: some View { 122 + Section(title) { 123 + Grid(alignment: .topLeading, horizontalSpacing: 16, verticalSpacing: 8) { 124 + ForEach(rows) { row in 125 + GridRow { 126 + Text(row.url) 127 + .font(.body.monospaced()) 128 + .gridColumnAlignment(.leading) 129 + Group { 130 + if let params = row.params { 131 + Text("\(row.description) Optional: \(code(params)).") 132 + } else { 133 + Text(row.description) 134 + } 135 + } 136 + .foregroundStyle(.secondary) 137 + .gridColumnAlignment(.leading) 138 + } 139 + } 140 + } 141 + } 142 + } 143 + } 144 + 145 + struct DeeplinkCheatsheetMenuButton: View { 146 + @Environment(\.openWindow) private var openWindow 147 + 148 + var body: some View { 149 + Button("Deeplink Reference") { 150 + openWindow(id: WindowID.deeplinkCheatsheet) 151 + } 152 + .help("Open the deeplink cheatsheet.") 153 + } 154 + } 155 + 156 + // MARK: - Deeplink → window bridge. 157 + 158 + /// Opens the deeplink cheatsheet window when the reducer sets `isDeeplinkCheatsheetRequested`. 159 + struct OpenDeeplinkCheatsheetBridge: ViewModifier { 160 + @Environment(\.openWindow) private var openWindow 161 + let store: StoreOf<AppFeature> 162 + 163 + func body(content: Content) -> some View { 164 + content 165 + .onChange(of: store.isDeeplinkCheatsheetRequested) { _, requested in 166 + guard requested else { return } 167 + openWindow(id: WindowID.deeplinkCheatsheet) 168 + store.send(.deeplinkCheatsheetOpened) 169 + } 170 + } 171 + } 172 + 173 + extension View { 174 + func openDeeplinkCheatsheetOnRequest(store: StoreOf<AppFeature>) -> some View { 175 + modifier(OpenDeeplinkCheatsheetBridge(store: store)) 176 + } 177 + } 178 + 179 + /// Inline code fragment styled as primary foreground. 180 + private func code(_ value: String) -> Text { 181 + Text("`\(value)`").foregroundStyle(.primary) 182 + }
+1
supacode/App/WindowID.swift
··· 2 2 enum WindowID { 3 3 static let main = "main" 4 4 static let settings = "settings" 5 + static let deeplinkCheatsheet = "deeplink-cheatsheet" 5 6 }
+41 -1
supacode/App/supacodeApp.swift
··· 31 31 32 32 @MainActor 33 33 final class SupacodeAppDelegate: NSObject, NSApplicationDelegate { 34 - var appStore: StoreOf<AppFeature>? 34 + var appStore: StoreOf<AppFeature>? { 35 + didSet { 36 + guard let appStore else { return } 37 + // Replay any deeplinks that arrived before the store was initialized. 38 + let buffered = bufferedDeeplinkURLs 39 + bufferedDeeplinkURLs.removeAll() 40 + for url in buffered { 41 + appStore.send(.deeplinkReceived(url)) 42 + } 43 + } 44 + } 35 45 var terminalManager: WorktreeTerminalManager? 46 + private var bufferedDeeplinkURLs: [URL] = [] 36 47 37 48 func applicationWillTerminate(_ notification: Notification) { 38 49 terminalManager?.saveAllLayoutSnapshots() ··· 57 68 return showMainWindow(from: sender) ? false : true 58 69 } 59 70 71 + func application(_ application: NSApplication, open urls: [URL]) { 72 + guard let appStore else { 73 + SupaLogger("Deeplink").warning("Deeplink received before store initialized, buffering: \(urls)") 74 + bufferedDeeplinkURLs.append(contentsOf: urls) 75 + return 76 + } 77 + for url in urls { 78 + appStore.send(.deeplinkReceived(url)) 79 + } 80 + } 81 + 60 82 func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { 61 83 false 62 84 } ··· 167 189 }, 168 190 events: { 169 191 terminalManager.eventStream() 192 + }, 193 + tabExists: { worktreeID, tabID in 194 + terminalManager.tabExists(worktreeID: worktreeID, tabID: tabID) 195 + }, 196 + surfaceExists: { worktreeID, tabID, surfaceID in 197 + terminalManager.surfaceExists(worktreeID: worktreeID, tabID: tabID, surfaceID: surfaceID) 170 198 } 171 199 ) 172 200 values.worktreeInfoWatcher = WorktreeInfoWatcherClient( ··· 191 219 .environment(commandKeyObserver) 192 220 } 193 221 .openSettingsOnSelection(store: store) 222 + .openDeeplinkCheatsheetOnRequest(store: store) 194 223 } 224 + .handlesExternalEvents(matching: []) 195 225 .environment(ghosttyShortcuts) 196 226 .environment(commandKeyObserver) 197 227 .commands { ··· 227 257 } 228 258 } 229 259 CommandGroup(replacing: .help) { 260 + DeeplinkCheatsheetMenuButton() 261 + Divider() 230 262 Button("Submit GitHub Issue") { 231 263 guard let url = URL(string: "https://github.com/supabitapp/supacode/issues/new") else { return } 232 264 NSWorkspace.shared.open(url) ··· 248 280 .toolbarBackground(.hidden, for: .windowToolbar) 249 281 .toolbarColorScheme(store.settings.appearanceMode.colorScheme, for: .windowToolbar) 250 282 } 283 + .handlesExternalEvents(matching: []) 251 284 .windowToolbarStyle(.unified) 252 285 .defaultSize(width: 800, height: 600) 286 + .restorationBehavior(.disabled) 287 + Window("Deeplink Reference", id: WindowID.deeplinkCheatsheet) { 288 + DeeplinkCheatsheetView() 289 + } 290 + .handlesExternalEvents(matching: []) 291 + .windowToolbarStyle(.unified) 292 + .defaultSize(width: 720, height: 640) 253 293 .restorationBehavior(.disabled) 254 294 } 255 295 }
+254
supacode/Clients/Deeplink/DeeplinkClient.swift
··· 1 + import ComposableArchitecture 2 + import Foundation 3 + 4 + struct DeeplinkClient: Sendable { 5 + var parse: @Sendable (URL) -> Deeplink? 6 + } 7 + 8 + extension DeeplinkClient: DependencyKey { 9 + static let liveValue = DeeplinkClient { DeeplinkParser.parse($0) } 10 + static let testValue = DeeplinkClient(parse: unimplemented("DeeplinkClient.parse", placeholder: nil)) 11 + } 12 + 13 + extension DependencyValues { 14 + var deeplinkClient: DeeplinkClient { 15 + get { self[DeeplinkClient.self] } 16 + set { self[DeeplinkClient.self] = newValue } 17 + } 18 + } 19 + 20 + // MARK: - Parser. 21 + 22 + private nonisolated enum DeeplinkParser { 23 + private static let logger = SupaLogger("Deeplink") 24 + 25 + static func parse(_ url: URL) -> Deeplink? { 26 + guard url.scheme == "supacode" else { 27 + logger.debug("Ignoring non-supacode URL: \(url.scheme ?? "nil")") 28 + return nil 29 + } 30 + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { 31 + logger.warning("Failed to parse URL components: \(url)") 32 + return nil 33 + } 34 + // For custom-scheme URLs, the host acts as the top-level route (e.g., "worktree", "repo", "settings"). 35 + guard let host = components.host, !host.isEmpty else { return .open } 36 + // url.path() defaults to percentEncoded: true, keeping %2F intact so encoded slashes 37 + // are not split as path separators. components.path is percent-decoded, which would 38 + // incorrectly split worktree IDs containing literal slashes. 39 + let pathSegments = url.path() 40 + .split(separator: "/", omittingEmptySubsequences: true) 41 + .map(String.init) 42 + let queryItems = components.queryItems ?? [] 43 + 44 + switch host { 45 + case "worktree": 46 + return parseWorktree(pathSegments: pathSegments, queryItems: queryItems) 47 + case "repo": 48 + return parseRepo(pathSegments: pathSegments, queryItems: queryItems) 49 + case "help": 50 + return .help 51 + case "settings": 52 + // settings/repo/<encoded-repo-id> → open repository settings. 53 + if pathSegments.first == "repo" { 54 + guard pathSegments.count >= 2, 55 + let repositoryID = pathSegments[1].removingPercentEncoding, !repositoryID.isEmpty 56 + else { 57 + logger.warning("Settings repo deeplink missing or invalid repository ID.") 58 + return nil 59 + } 60 + return .settingsRepo(repositoryID: repositoryID) 61 + } 62 + let section: Deeplink.DeeplinkSettingsSection? = pathSegments.first.flatMap { raw in 63 + guard let parsed = Deeplink.DeeplinkSettingsSection(rawValue: raw) else { 64 + logger.warning("Unrecognized settings section: \(raw).") 65 + return nil 66 + } 67 + return parsed 68 + } 69 + return .settings(section: section) 70 + default: 71 + logger.warning("Unrecognized deeplink host: \(host)") 72 + return nil 73 + } 74 + } 75 + 76 + // MARK: - Worktree. 77 + 78 + private static func parseWorktree( 79 + pathSegments: [String], 80 + queryItems: [URLQueryItem] 81 + ) -> Deeplink? { 82 + // Expected: <percent-encoded-worktree-id>[/<action>[/<sub-path>...]]. 83 + guard !pathSegments.isEmpty else { 84 + logger.warning("Worktree deeplink missing id") 85 + return nil 86 + } 87 + guard let rawWorktreeID = pathSegments[0].removingPercentEncoding, !rawWorktreeID.isEmpty else { 88 + logger.warning("Failed to percent-decode worktree ID") 89 + return nil 90 + } 91 + // Normalize trailing slashes so that IDs with and without a trailing 92 + // slash resolve to the same worktree. 93 + let worktreeID = rawWorktreeID.hasSuffix("/") ? String(rawWorktreeID.dropLast()) : rawWorktreeID 94 + guard pathSegments.count >= 2 else { 95 + return .worktree(id: worktreeID, action: .select) 96 + } 97 + let action = pathSegments[1] 98 + 99 + switch action { 100 + case "run": 101 + return .worktree(id: worktreeID, action: .run) 102 + case "stop": 103 + return .worktree(id: worktreeID, action: .stop) 104 + case "archive": 105 + return .worktree(id: worktreeID, action: .archive) 106 + case "unarchive": 107 + return .worktree(id: worktreeID, action: .unarchive) 108 + case "delete": 109 + return .worktree(id: worktreeID, action: .delete) 110 + case "pin": 111 + return .worktree(id: worktreeID, action: .pin) 112 + case "unpin": 113 + return .worktree(id: worktreeID, action: .unpin) 114 + case "tab": 115 + return parseWorktreeTab( 116 + worktreeID: worktreeID, 117 + pathSegments: pathSegments, 118 + queryItems: queryItems, 119 + ) 120 + default: 121 + logger.warning("Unrecognized worktree action: \(action)") 122 + return nil 123 + } 124 + } 125 + 126 + private static func parseWorktreeTab( 127 + worktreeID: Worktree.ID, 128 + pathSegments: [String], 129 + queryItems: [URLQueryItem] 130 + ) -> Deeplink? { 131 + // "tab/<tab-uuid>" → focus tab. 132 + // "tab/new" → create new tab. 133 + // "tab/<tab-uuid>/destroy" → close tab. 134 + // "tab/<tab-uuid>/surface/<surface-uuid>" → focus surface. 135 + // "tab/<tab-uuid>/surface/<surface-uuid>/split" → split surface. 136 + // "tab/<tab-uuid>/surface/<surface-uuid>/destroy" → close surface. 137 + guard pathSegments.count >= 3 else { 138 + logger.warning("Tab deeplink missing sub-action or tab ID") 139 + return nil 140 + } 141 + let thirdSegment = pathSegments[2] 142 + 143 + if thirdSegment == "new" { 144 + let input = queryItems.first(where: { $0.name == "input" })?.value 145 + let id = queryItems.first(where: { $0.name == "id" })?.value.flatMap(UUID.init(uuidString:)) 146 + return .worktree(id: worktreeID, action: .tabNew(input: input, id: id)) 147 + } 148 + 149 + guard let tabUUID = UUID(uuidString: thirdSegment) else { 150 + logger.warning("Invalid tab UUID: \(thirdSegment)") 151 + return nil 152 + } 153 + 154 + if pathSegments.count >= 4, pathSegments[3] == "destroy" { 155 + return .worktree(id: worktreeID, action: .tabDestroy(tabID: tabUUID)) 156 + } 157 + 158 + // Check for surface sub-path: tab/<tab-uuid>/surface/<surface-uuid>[/split|/destroy]. 159 + if pathSegments.count >= 5, pathSegments[3] == "surface" { 160 + return parseWorktreeSurface( 161 + worktreeID: worktreeID, 162 + tabUUID: tabUUID, 163 + pathSegments: pathSegments, 164 + queryItems: queryItems, 165 + ) 166 + } 167 + 168 + return .worktree(id: worktreeID, action: .tab(tabID: tabUUID)) 169 + } 170 + 171 + private static func parseWorktreeSurface( 172 + worktreeID: Worktree.ID, 173 + tabUUID: UUID, 174 + pathSegments: [String], 175 + queryItems: [URLQueryItem] 176 + ) -> Deeplink? { 177 + guard let surfaceUUID = UUID(uuidString: pathSegments[4]) else { 178 + logger.warning("Invalid surface UUID: \(pathSegments[4])") 179 + return nil 180 + } 181 + 182 + let input = queryItems.first(where: { $0.name == "input" })?.value 183 + 184 + if pathSegments.count >= 6, pathSegments[5] == "destroy" { 185 + return .worktree( 186 + id: worktreeID, 187 + action: .surfaceDestroy(tabID: tabUUID, surfaceID: surfaceUUID), 188 + ) 189 + } 190 + 191 + if pathSegments.count >= 6, pathSegments[5] == "split" { 192 + let directionRaw = queryItems.first(where: { $0.name == "direction" })?.value ?? "horizontal" 193 + guard let direction = SplitDirection(rawValue: directionRaw) else { 194 + logger.warning("Invalid split direction '\(directionRaw)'.") 195 + return nil 196 + } 197 + let id = queryItems.first(where: { $0.name == "id" })?.value.flatMap(UUID.init(uuidString:)) 198 + return .worktree( 199 + id: worktreeID, 200 + action: .surfaceSplit(tabID: tabUUID, surfaceID: surfaceUUID, direction: direction, input: input, id: id), 201 + ) 202 + } 203 + 204 + return .worktree( 205 + id: worktreeID, 206 + action: .surface(tabID: tabUUID, surfaceID: surfaceUUID, input: input), 207 + ) 208 + } 209 + 210 + // MARK: - Repo. 211 + 212 + private static func parseRepo( 213 + pathSegments: [String], 214 + queryItems: [URLQueryItem] 215 + ) -> Deeplink? { 216 + // "open" → add repository. 217 + // "<encoded-id>/worktree/new" → create worktree. 218 + guard let first = pathSegments.first else { 219 + logger.warning("Repo deeplink missing action or id") 220 + return nil 221 + } 222 + 223 + if first == "open" { 224 + guard let pathValue = queryItems.first(where: { $0.name == "path" })?.value, 225 + !pathValue.isEmpty, pathValue.hasPrefix("/") 226 + else { 227 + logger.warning("Repo open deeplink missing or invalid path query param.") 228 + return nil 229 + } 230 + return .repoOpen(path: URL(fileURLWithPath: pathValue)) 231 + } 232 + 233 + guard let repositoryID = first.removingPercentEncoding, !repositoryID.isEmpty else { 234 + logger.warning("Failed to percent-decode repository ID") 235 + return nil 236 + } 237 + guard pathSegments.count >= 3, 238 + pathSegments[1] == "worktree", 239 + pathSegments[2] == "new" 240 + else { 241 + logger.warning("Unrecognized repo deeplink path") 242 + return nil 243 + } 244 + let branch = queryItems.first(where: { $0.name == "branch" })?.value 245 + let baseRef = queryItems.first(where: { $0.name == "base" })?.value 246 + let fetchOrigin = queryItems.first(where: { $0.name == "fetch" })?.value == "true" 247 + return .repoWorktreeNew( 248 + repositoryID: repositoryID, 249 + branch: branch, 250 + baseRef: baseRef, 251 + fetchOrigin: fetchOrigin, 252 + ) 253 + } 254 + }
+17 -5
supacode/Clients/Terminal/TerminalClient.swift
··· 4 4 struct TerminalClient { 5 5 var send: @MainActor @Sendable (Command) -> Void 6 6 var events: @MainActor @Sendable () -> AsyncStream<Event> 7 + var tabExists: @MainActor @Sendable (Worktree.ID, TerminalTabID) -> Bool 8 + var surfaceExists: @MainActor @Sendable (Worktree.ID, TerminalTabID, UUID) -> Bool 7 9 8 10 enum Command: Equatable { 9 - case createTab(Worktree, runSetupScriptIfNew: Bool) 10 - case createTabWithInput(Worktree, input: String, runSetupScriptIfNew: Bool) 11 + case createTab(Worktree, runSetupScriptIfNew: Bool, id: UUID? = nil) 12 + case createTabWithInput(Worktree, input: String, runSetupScriptIfNew: Bool, id: UUID? = nil) 11 13 case ensureInitialTab(Worktree, runSetupScriptIfNew: Bool, focusing: Bool) 12 14 case stopRunScript(Worktree) 13 - case selectTab(Worktree, tabId: TerminalTabID) 14 15 case runBlockingScript(Worktree, kind: BlockingScriptKind, script: String) 15 16 case closeFocusedTab(Worktree) 16 17 case closeFocusedSurface(Worktree) ··· 20 21 case navigateSearchNext(Worktree) 21 22 case navigateSearchPrevious(Worktree) 22 23 case endSearch(Worktree) 24 + case selectTab(Worktree, tabID: TerminalTabID) 25 + case focusSurface(Worktree, tabID: TerminalTabID, surfaceID: UUID, input: String? = nil) 26 + case splitSurface( 27 + Worktree, tabID: TerminalTabID, surfaceID: UUID, direction: SplitDirection, 28 + input: String?, id: UUID? = nil) 29 + case destroyTab(Worktree, tabID: TerminalTabID) 30 + case destroySurface(Worktree, tabID: TerminalTabID, surfaceID: UUID) 23 31 case prune(Set<Worktree.ID>) 24 32 case setNotificationsEnabled(Bool) 25 33 case setSelectedWorktreeID(Worktree.ID?) ··· 43 51 extension TerminalClient: DependencyKey { 44 52 static let liveValue = TerminalClient( 45 53 send: { _ in fatalError("TerminalClient.send not configured") }, 46 - events: { fatalError("TerminalClient.events not configured") } 54 + events: { fatalError("TerminalClient.events not configured") }, 55 + tabExists: { _, _ in fatalError("TerminalClient.tabExists not configured") }, 56 + surfaceExists: { _, _, _ in fatalError("TerminalClient.surfaceExists not configured") } 47 57 ) 48 58 49 59 static let testValue = TerminalClient( 50 60 send: { _ in }, 51 - events: { AsyncStream { $0.finish() } } 61 + events: { AsyncStream { $0.finish() } }, 62 + tabExists: unimplemented("TerminalClient.tabExists", placeholder: true), 63 + surfaceExists: unimplemented("TerminalClient.surfaceExists", placeholder: true) 52 64 ) 53 65 } 54 66
+45
supacode/Domain/Deeplink.swift
··· 1 + import Foundation 2 + 3 + /// A parsed deeplink action from a `supacode://` URL. 4 + enum Deeplink: Equatable, Sendable { 5 + case open 6 + case help 7 + case worktree(id: Worktree.ID, action: WorktreeAction) 8 + case repoOpen(path: URL) 9 + case repoWorktreeNew( 10 + repositoryID: Repository.ID, 11 + branch: String?, 12 + baseRef: String?, 13 + fetchOrigin: Bool 14 + ) 15 + case settings(section: DeeplinkSettingsSection?) 16 + case settingsRepo(repositoryID: Repository.ID) 17 + 18 + enum WorktreeAction: Equatable, Sendable { 19 + case select 20 + case run 21 + case stop 22 + case archive 23 + case unarchive 24 + case delete 25 + case pin 26 + case unpin 27 + case tab(tabID: UUID) 28 + case tabNew(input: String?, id: UUID?) 29 + case tabDestroy(tabID: UUID) 30 + case surface(tabID: UUID, surfaceID: UUID, input: String?) 31 + case surfaceSplit(tabID: UUID, surfaceID: UUID, direction: SplitDirection, input: String?, id: UUID?) 32 + case surfaceDestroy(tabID: UUID, surfaceID: UUID) 33 + } 34 + 35 + /// Settings sections reachable via deeplink. 36 + enum DeeplinkSettingsSection: String, Equatable, Sendable { 37 + case general 38 + case notifications 39 + case worktrees 40 + case codingAgents 41 + case shortcuts 42 + case updates 43 + case github 44 + } 45 + }
+5
supacode/Domain/SplitDirection.swift
··· 1 + /// Direction for terminal surface splits. 2 + enum SplitDirection: String, Codable, Equatable, Sendable { 3 + case horizontal 4 + case vertical 5 + }
+1 -8
supacode/Domain/Worktree.swift
··· 26 26 } 27 27 28 28 extension Worktree { 29 - /// Environment variables exposed to all Supacode scripts. 29 + /// Base environment variables for Supacode scripts (supplemented per-surface). 30 30 var scriptEnvironment: [String: String] { 31 31 [ 32 32 "SUPACODE_WORKTREE_PATH": workingDirectory.path(percentEncoded: false), ··· 34 34 ] 35 35 } 36 36 37 - /// Shell export statements for prepending to scripts. 38 - var scriptEnvironmentExportPrefix: String { 39 - scriptEnvironment 40 - .sorted(by: { $0.key < $1.key }) 41 - .map { "export \($0.key)='\($0.value.replacing("'", with: "'\"'\"'"))'" } 42 - .joined(separator: "\n") + "\n" 43 - } 44 37 }
+494 -18
supacode/Features/App/Reducer/AppFeature.swift
··· 4 4 import PostHog 5 5 import SwiftUI 6 6 7 + private nonisolated let deeplinkLogger = SupaLogger("Deeplink") 8 + 7 9 private enum CancelID { 8 10 static let periodicRefresh = "app.periodicRefresh" 9 11 } ··· 22 24 var isRunScriptPromptPresented = false 23 25 var notificationIndicatorCount: Int = 0 24 26 var lastKnownSystemNotificationsEnabled: Bool 27 + var pendingDeeplinks: [Deeplink] = [] 28 + var isDeeplinkCheatsheetRequested = false 25 29 @Presents var alert: AlertState<Alert>? 30 + @Presents var deeplinkInputConfirmation: DeeplinkInputConfirmationFeature.State? 26 31 27 32 init( 28 33 repositories: RepositoriesFeature.State = .init(), ··· 61 66 case navigateSearchPrevious 62 67 case endSearch 63 68 case systemNotificationsPermissionFailed(errorMessage: String?) 69 + case deeplinkReceived(URL) 70 + case deeplink(Deeplink) 71 + case deeplinkCheatsheetOpened 64 72 case alert(PresentationAction<Alert>) 73 + case deeplinkInputConfirmation(PresentationAction<DeeplinkInputConfirmationFeature.Action>) 65 74 case terminalEvent(TerminalClient.Event) 66 75 } 67 76 ··· 71 80 } 72 81 73 82 @Dependency(AnalyticsClient.self) private var analyticsClient 83 + @Dependency(DeeplinkClient.self) private var deeplinkClient 74 84 @Dependency(RepositoryPersistenceClient.self) private var repositoryPersistence 75 85 @Dependency(WorkspaceClient.self) private var workspaceClient 76 86 @Dependency(NotificationSoundClient.self) private var notificationSoundClient ··· 191 201 state.repositories.runScriptWorktreeIDs.formIntersection(ids) 192 202 let recencyIDs = CommandPaletteFeature.recencyRetentionIDs(from: repositories) 193 203 let worktrees = state.repositories.worktreesForInfoWatcher() 194 - if case .repository(let repositoryID)? = state.settings.selection, 195 - !repositories.contains(where: { $0.id == repositoryID }) 196 - { 197 - return .merge( 198 - .send(.settings(.repositoriesChanged(repositories))), 199 - .send(.settings(.setSelection(.general))), 200 - .send(.commandPalette(.pruneRecency(recencyIDs))), 201 - .run { _ in 202 - await terminalClient.send(.prune(ids)) 203 - }, 204 - .run { _ in 205 - await worktreeInfoWatcher.send(.setWorktrees(worktrees)) 206 - } 207 - ) 208 - } 209 - return .merge( 204 + var effects: [Effect<Action>] = [ 210 205 .send(.settings(.repositoriesChanged(repositories))), 211 206 .send(.commandPalette(.pruneRecency(recencyIDs))), 212 207 .run { _ in ··· 214 209 }, 215 210 .run { _ in 216 211 await worktreeInfoWatcher.send(.setWorktrees(worktrees)) 212 + }, 213 + ] 214 + if case .repository(let repositoryID)? = state.settings.selection, 215 + !repositories.contains(where: { $0.id == repositoryID }) 216 + { 217 + effects.append(.send(.settings(.setSelection(.general)))) 218 + } 219 + if !state.pendingDeeplinks.isEmpty { 220 + let pending = state.pendingDeeplinks 221 + state.pendingDeeplinks.removeAll() 222 + for deeplink in pending { 223 + effects.append(.send(.deeplink(deeplink))) 217 224 } 218 - ) 225 + } 226 + return .merge(effects) 219 227 220 228 case .repositories(.delegate(.openRepositorySettings(let repositoryID))): 221 229 guard state.repositories.repositories.contains(where: { $0.id == repositoryID }) else { ··· 231 239 case .repositories(.delegate(.selectTerminalTab(let worktreeID, let tabId))): 232 240 guard let worktree = state.repositories.worktree(for: worktreeID) else { return .none } 233 241 return .run { _ in 234 - await terminalClient.send(.selectTab(worktree, tabId: tabId)) 242 + await terminalClient.send(.selectTab(worktree, tabID: tabId)) 235 243 } 236 244 237 245 case .settings(.setSelection(let selection)): ··· 563 571 state.selectedRunScript = settings.runScript 564 572 return .none 565 573 574 + case .deeplinkReceived(let url): 575 + let deeplinkClient = deeplinkClient 576 + guard let parsed = deeplinkClient.parse(url) else { 577 + deeplinkLogger.warning("Failed to parse deeplink URL: \(url)") 578 + if url.scheme == "supacode" { 579 + state.alert = AlertState { 580 + TextState("Invalid deeplink") 581 + } actions: { 582 + ButtonState(role: .cancel, action: .dismiss) { 583 + TextState("OK") 584 + } 585 + } message: { 586 + TextState("The deeplink URL could not be recognized: \(url.absoluteString)") 587 + } 588 + } 589 + return .none 590 + } 591 + guard state.repositories.isInitialLoadComplete else { 592 + state.pendingDeeplinks.append(parsed) 593 + return .none 594 + } 595 + return .send(.deeplink(parsed)) 596 + 597 + case .deeplink(let deeplink): 598 + return handleDeeplink(deeplink, state: &state) 599 + 600 + case .deeplinkCheatsheetOpened: 601 + state.isDeeplinkCheatsheetRequested = false 602 + return .none 603 + 566 604 case .systemNotificationsPermissionFailed(let errorMessage): 567 605 return .concatenate( 568 606 .send(.settings(.setSystemNotificationsEnabled(false))), ··· 582 620 583 621 case .alert: 584 622 return .none 623 + 624 + case .deeplinkInputConfirmation( 625 + .presented(.delegate(.confirm(let worktreeID, let confirmedAction, let alwaysAllow)))): 626 + state.deeplinkInputConfirmation = nil 627 + // The initial deeplink dispatch already selected the worktree via 628 + // `handleWorktreeDeeplink`. Re-dispatch only the action effect, skipping 629 + // the redundant select. 630 + let actionEffect = worktreeActionEffect( 631 + worktreeID: worktreeID, 632 + action: confirmedAction, 633 + state: &state, 634 + bypassConfirmation: true, 635 + ) 636 + guard alwaysAllow else { return actionEffect } 637 + return .concatenate( 638 + .send(.settings(.setAllowArbitraryDeeplinkInput(true))), 639 + actionEffect, 640 + ) 641 + 642 + case .deeplinkInputConfirmation(.presented(.delegate(.cancel))): 643 + state.deeplinkInputConfirmation = nil 644 + return .none 645 + 646 + case .deeplinkInputConfirmation: 647 + return .none 648 + 649 + case .repositories(.repositoriesLoaded), .repositories(.openRepositoriesFinished): 650 + // Flush pending deeplinks after initial load completes, even when repositoriesChanged 651 + // delegate does not fire (e.g., zero repos loaded with no state change). 652 + guard !state.pendingDeeplinks.isEmpty else { return .none } 653 + let pending = state.pendingDeeplinks 654 + state.pendingDeeplinks.removeAll() 655 + return .merge(pending.map { .send(.deeplink($0)) }) 585 656 586 657 case .repositories: 587 658 return .none ··· 725 796 Scope(state: \.commandPalette, action: \.commandPalette) { 726 797 CommandPaletteFeature() 727 798 } 799 + .ifLet(\.$deeplinkInputConfirmation, action: \.deeplinkInputConfirmation) { 800 + DeeplinkInputConfirmationFeature() 801 + } 802 + } 803 + 804 + // MARK: - Deeplink handling. 805 + 806 + // MARK: Deeplink dispatch. 807 + 808 + private func handleDeeplink( 809 + _ deeplink: Deeplink, 810 + state: inout State 811 + ) -> Effect<Action> { 812 + switch deeplink { 813 + case .open: 814 + return .run { @MainActor _ in 815 + let app = NSApplication.shared 816 + guard let window = app.windows.first(where: { $0.identifier?.rawValue == WindowID.main }) 817 + else { 818 + app.activate() 819 + return 820 + } 821 + if window.isMiniaturized { 822 + window.deminiaturize(nil) 823 + } 824 + window.makeKeyAndOrderFront(nil) 825 + app.activate() 826 + } 827 + case .help: 828 + state.isDeeplinkCheatsheetRequested = true 829 + return .none 830 + case .worktree(let worktreeID, let action): 831 + return handleWorktreeDeeplink(worktreeID: worktreeID, action: action, state: &state) 832 + case .repoOpen(let path): 833 + return .send(.repositories(.openRepositories([path]))) 834 + case .repoWorktreeNew(let repositoryID, let branch, let baseRef, let fetchOrigin): 835 + guard state.repositories.repositories[id: repositoryID] != nil else { 836 + deeplinkLogger.warning("Repository not found: \(repositoryID)") 837 + state.alert = AlertState { 838 + TextState("Repository not found") 839 + } actions: { 840 + ButtonState(role: .cancel, action: .dismiss) { 841 + TextState("OK") 842 + } 843 + } message: { 844 + TextState("No repository matching the deeplink could be found.") 845 + } 846 + return .none 847 + } 848 + guard let branch else { 849 + return .send(.repositories(.createRandomWorktreeInRepository(repositoryID))) 850 + } 851 + return .send( 852 + .repositories( 853 + .createWorktreeInRepository( 854 + repositoryID: repositoryID, 855 + nameSource: .explicit(branch), 856 + baseRefSource: baseRef.map { .explicit($0) } ?? .repositorySetting, 857 + fetchOrigin: fetchOrigin, 858 + ) 859 + ) 860 + ) 861 + case .settings(let section): 862 + return handleSettingsDeeplink(section: section) 863 + case .settingsRepo(let repositoryID): 864 + guard state.repositories.repositories[id: repositoryID] != nil else { 865 + deeplinkLogger.warning("Repository not found for settings deeplink: \(repositoryID)") 866 + state.alert = AlertState { 867 + TextState("Repository not found") 868 + } actions: { 869 + ButtonState(role: .cancel, action: .dismiss) { 870 + TextState("OK") 871 + } 872 + } message: { 873 + TextState("No repository matching the deeplink could be found.") 874 + } 875 + return .none 876 + } 877 + return .send(.settings(.setSelection(.repository(repositoryID)))) 878 + } 879 + } 880 + 881 + // MARK: Worktree deeplink dispatch. 882 + 883 + private func handleWorktreeDeeplink( 884 + worktreeID rawWorktreeID: Worktree.ID, 885 + action: Deeplink.WorktreeAction, 886 + state: inout State, 887 + bypassConfirmation: Bool = false 888 + ) -> Effect<Action> { 889 + let worktreeID = resolveWorktreeID(rawWorktreeID, state: state) 890 + guard state.repositories.worktree(for: worktreeID) != nil else { 891 + deeplinkLogger.warning("Worktree not found: \(rawWorktreeID)") 892 + state.alert = AlertState { 893 + TextState("Worktree not found") 894 + } actions: { 895 + ButtonState(role: .cancel, action: .dismiss) { 896 + TextState("OK") 897 + } 898 + } message: { 899 + TextState("No worktree matching the deeplink could be found. It may have been removed.") 900 + } 901 + return .none 902 + } 903 + 904 + let selectEffect: Effect<Action> = 905 + .send(.repositories(.selectWorktree(worktreeID, focusTerminal: true))) 906 + let actionEffect = worktreeActionEffect( 907 + worktreeID: worktreeID, 908 + action: action, 909 + state: &state, 910 + bypassConfirmation: bypassConfirmation, 911 + ) 912 + return .concatenate(selectEffect, actionEffect) 913 + } 914 + 915 + // swiftlint:disable:next cyclomatic_complexity function_body_length 916 + private func worktreeActionEffect( 917 + worktreeID: Worktree.ID, 918 + action: Deeplink.WorktreeAction, 919 + state: inout State, 920 + bypassConfirmation: Bool 921 + ) -> Effect<Action> { 922 + switch action { 923 + case .select: 924 + return .none 925 + case .run: 926 + return .send(.runScript) 927 + case .stop: 928 + return .send(.stopRunScript) 929 + case .archive: 930 + guard let repositoryID = resolveRepositoryID(for: worktreeID, label: "archive", state: &state) else { 931 + return .none 932 + } 933 + return .send(.repositories(.requestArchiveWorktree(worktreeID, repositoryID))) 934 + case .unarchive: 935 + return .send(.repositories(.unarchiveWorktree(worktreeID))) 936 + case .delete: 937 + return deeplinkDeleteWorktreeEffect( 938 + worktreeID: worktreeID, 939 + action: action, 940 + state: &state, 941 + bypassConfirmation: bypassConfirmation, 942 + ) 943 + case .pin: 944 + return .send(.repositories(.pinWorktree(worktreeID))) 945 + case .unpin: 946 + return .send(.repositories(.unpinWorktree(worktreeID))) 947 + case .tab(let tabID): 948 + guard validateTab(worktreeID: worktreeID, tabID: tabID, state: &state) else { return .none } 949 + return sendTerminalCommand(worktreeID: worktreeID, state: state) { worktree in 950 + .selectTab(worktree, tabID: TerminalTabID(rawValue: tabID)) 951 + } 952 + case .tabNew(let input, let id): 953 + guard let input, !input.isEmpty else { 954 + return sendTerminalCommand(worktreeID: worktreeID, state: state) { worktree in 955 + .createTab(worktree, runSetupScriptIfNew: true, id: id) 956 + } 957 + } 958 + if requiresInputConfirmation(state: state, bypassConfirmation: bypassConfirmation) { 959 + presentDeeplinkConfirmation( 960 + worktreeID: worktreeID, message: .command(input), 961 + action: action, state: &state) 962 + return .none 963 + } 964 + return sendTerminalCommand(worktreeID: worktreeID, state: state) { worktree in 965 + .createTabWithInput(worktree, input: input, runSetupScriptIfNew: false, id: id) 966 + } 967 + case .tabDestroy(let tabID): 968 + guard validateTab(worktreeID: worktreeID, tabID: tabID, state: &state) else { return .none } 969 + guard bypassConfirmation || state.settings.allowArbitraryDeeplinkInput else { 970 + presentDeeplinkConfirmation( 971 + worktreeID: worktreeID, message: .confirmation("Close tab \(tabID.uuidString.prefix(8))…?"), 972 + action: action, state: &state) 973 + return .none 974 + } 975 + return sendTerminalCommand(worktreeID: worktreeID, state: state) { worktree in 976 + .destroyTab(worktree, tabID: TerminalTabID(rawValue: tabID)) 977 + } 978 + case .surface(let tabID, let surfaceID, let input): 979 + guard validateSurface(worktreeID: worktreeID, tabID: tabID, surfaceID: surfaceID, state: &state) else { 980 + return .none 981 + } 982 + if let input, !input.isEmpty, 983 + requiresInputConfirmation(state: state, bypassConfirmation: bypassConfirmation) 984 + { 985 + presentDeeplinkConfirmation( 986 + worktreeID: worktreeID, message: .command(input), 987 + action: action, state: &state) 988 + return .none 989 + } 990 + return sendTerminalCommand(worktreeID: worktreeID, state: state) { worktree in 991 + .focusSurface(worktree, tabID: TerminalTabID(rawValue: tabID), surfaceID: surfaceID, input: input) 992 + } 993 + case .surfaceSplit(let tabID, let surfaceID, let direction, let input, let id): 994 + guard validateSurface(worktreeID: worktreeID, tabID: tabID, surfaceID: surfaceID, state: &state) else { 995 + return .none 996 + } 997 + if let input, !input.isEmpty, 998 + requiresInputConfirmation(state: state, bypassConfirmation: bypassConfirmation) 999 + { 1000 + presentDeeplinkConfirmation( 1001 + worktreeID: worktreeID, message: .command(input), 1002 + action: action, state: &state) 1003 + return .none 1004 + } 1005 + return sendTerminalCommand(worktreeID: worktreeID, state: state) { worktree in 1006 + .splitSurface( 1007 + worktree, tabID: TerminalTabID(rawValue: tabID), surfaceID: surfaceID, 1008 + direction: direction, input: input, id: id) 1009 + } 1010 + case .surfaceDestroy(let tabID, let surfaceID): 1011 + guard validateSurface(worktreeID: worktreeID, tabID: tabID, surfaceID: surfaceID, state: &state) else { 1012 + return .none 1013 + } 1014 + guard bypassConfirmation || state.settings.allowArbitraryDeeplinkInput else { 1015 + presentDeeplinkConfirmation( 1016 + worktreeID: worktreeID, message: .confirmation("Close surface \(surfaceID.uuidString.prefix(8))…?"), 1017 + action: action, state: &state) 1018 + return .none 1019 + } 1020 + return sendTerminalCommand(worktreeID: worktreeID, state: state) { worktree in 1021 + .destroySurface(worktree, tabID: TerminalTabID(rawValue: tabID), surfaceID: surfaceID) 1022 + } 1023 + } 1024 + } 1025 + 1026 + private func deeplinkDeleteWorktreeEffect( 1027 + worktreeID: Worktree.ID, 1028 + action: Deeplink.WorktreeAction, 1029 + state: inout State, 1030 + bypassConfirmation: Bool 1031 + ) -> Effect<Action> { 1032 + guard let repositoryID = resolveRepositoryID(for: worktreeID, label: "delete", state: &state) else { 1033 + return .none 1034 + } 1035 + if let worktree = state.repositories.worktree(for: worktreeID), 1036 + state.repositories.isMainWorktree(worktree) 1037 + { 1038 + state.alert = AlertState { 1039 + TextState("Delete not allowed") 1040 + } actions: { 1041 + ButtonState(role: .cancel, action: .dismiss) { 1042 + TextState("OK") 1043 + } 1044 + } message: { 1045 + TextState("Deleting the main worktree is not allowed.") 1046 + } 1047 + return .none 1048 + } 1049 + let worktreeName = state.repositories.worktree(for: worktreeID)?.name ?? worktreeID 1050 + guard bypassConfirmation || state.settings.allowArbitraryDeeplinkInput else { 1051 + presentDeeplinkConfirmation( 1052 + worktreeID: worktreeID, 1053 + message: .confirmation("Delete worktree \"\(worktreeName)\"?"), 1054 + action: action, 1055 + state: &state, 1056 + ) 1057 + return .none 1058 + } 1059 + return .send(.repositories(.deleteWorktreeConfirmed(worktreeID, repositoryID))) 1060 + } 1061 + 1062 + private func resolveRepositoryID( 1063 + for worktreeID: Worktree.ID, 1064 + label: String, 1065 + state: inout State 1066 + ) -> Repository.ID? { 1067 + guard let repositoryID = state.repositories.repositoryID(containing: worktreeID) else { 1068 + deeplinkLogger.warning("Repository not found for worktree \(worktreeID) during \(label)") 1069 + state.alert = AlertState { 1070 + TextState("\(label.capitalized) failed") 1071 + } actions: { 1072 + ButtonState(role: .cancel, action: .dismiss) { 1073 + TextState("OK") 1074 + } 1075 + } message: { 1076 + TextState("Could not resolve the repository for this worktree.") 1077 + } 1078 + return nil 1079 + } 1080 + return repositoryID 1081 + } 1082 + 1083 + // MARK: Confirmation helpers. 1084 + 1085 + /// Returns `true` when neither `bypassConfirmation` nor the allow-arbitrary setting is active. 1086 + private func requiresInputConfirmation( 1087 + state: State, 1088 + bypassConfirmation: Bool 1089 + ) -> Bool { 1090 + !bypassConfirmation && !state.settings.allowArbitraryDeeplinkInput 1091 + } 1092 + 1093 + // MARK: Terminal command dispatch. 1094 + 1095 + private func sendTerminalCommand( 1096 + worktreeID: Worktree.ID, 1097 + state: State, 1098 + command: (Worktree) -> TerminalClient.Command 1099 + ) -> Effect<Action> { 1100 + guard let worktree = state.repositories.worktree(for: worktreeID) else { 1101 + deeplinkLogger.warning("Worktree \(worktreeID) vanished before terminal command could be dispatched.") 1102 + return .none 1103 + } 1104 + let cmd = command(worktree) 1105 + let terminalClient = terminalClient 1106 + return .run { _ in await terminalClient.send(cmd) } 1107 + } 1108 + 1109 + private func presentDeeplinkConfirmation( 1110 + worktreeID: Worktree.ID, 1111 + message: DeeplinkConfirmationMessage, 1112 + action: Deeplink.WorktreeAction, 1113 + state: inout State 1114 + ) { 1115 + let worktreeName = state.repositories.worktree(for: worktreeID)?.name ?? "Unknown" 1116 + let repoName = state.repositories.repositoryID(containing: worktreeID) 1117 + .flatMap { state.repositories.repositories[id: $0]?.name } 1118 + state.deeplinkInputConfirmation = DeeplinkInputConfirmationFeature.State( 1119 + worktreeID: worktreeID, 1120 + worktreeName: worktreeName, 1121 + repositoryName: repoName, 1122 + message: message, 1123 + action: action, 1124 + ) 1125 + } 1126 + 1127 + // MARK: Validation helpers. 1128 + 1129 + /// Validates that a tab exists in the given worktree, showing an alert if not. 1130 + private func validateTab( 1131 + worktreeID: Worktree.ID, 1132 + tabID: UUID, 1133 + state: inout State 1134 + ) -> Bool { 1135 + guard terminalClient.tabExists(worktreeID, TerminalTabID(rawValue: tabID)) else { 1136 + deeplinkLogger.warning("Tab \(tabID) not found in worktree \(worktreeID)") 1137 + state.alert = AlertState { 1138 + TextState("Tab not found") 1139 + } actions: { 1140 + ButtonState(role: .cancel, action: .dismiss) { 1141 + TextState("OK") 1142 + } 1143 + } message: { 1144 + TextState("No tab matching the deeplink could be found. It may have been closed.") 1145 + } 1146 + return false 1147 + } 1148 + return true 1149 + } 1150 + 1151 + /// Validates that a tab and surface exist in the given worktree, showing an alert if not. 1152 + private func validateSurface( 1153 + worktreeID: Worktree.ID, 1154 + tabID: UUID, 1155 + surfaceID: UUID, 1156 + state: inout State 1157 + ) -> Bool { 1158 + guard validateTab(worktreeID: worktreeID, tabID: tabID, state: &state) else { return false } 1159 + guard terminalClient.surfaceExists(worktreeID, TerminalTabID(rawValue: tabID), surfaceID) else { 1160 + deeplinkLogger.warning("Surface \(surfaceID) not found in tab \(tabID) of worktree \(worktreeID)") 1161 + state.alert = AlertState { 1162 + TextState("Surface not found") 1163 + } actions: { 1164 + ButtonState(role: .cancel, action: .dismiss) { 1165 + TextState("OK") 1166 + } 1167 + } message: { 1168 + TextState("No surface matching the deeplink could be found. It may have been closed.") 1169 + } 1170 + return false 1171 + } 1172 + return true 1173 + } 1174 + 1175 + /// Resolves a worktree ID, trying the raw value first then appending a trailing 1176 + /// slash since stored IDs derived from `standardizedFileURL` for directories include one. 1177 + private func resolveWorktreeID( 1178 + _ rawID: Worktree.ID, 1179 + state: State 1180 + ) -> Worktree.ID { 1181 + guard state.repositories.worktree(for: rawID) == nil else { return rawID } 1182 + let alternate = rawID + "/" 1183 + guard state.repositories.worktree(for: alternate) != nil else { return rawID } 1184 + return alternate 1185 + } 1186 + 1187 + // MARK: Settings deeplink. 1188 + 1189 + private func handleSettingsDeeplink(section: Deeplink.DeeplinkSettingsSection?) -> Effect<Action> { 1190 + guard let section else { 1191 + return .send(.settings(.setSelection(.general))) 1192 + } 1193 + let settingsSection: SettingsSection = 1194 + switch section { 1195 + case .general: .general 1196 + case .notifications: .notifications 1197 + case .worktrees: .worktree 1198 + case .codingAgents: .codingAgents 1199 + case .shortcuts: .shortcuts 1200 + case .updates: .updates 1201 + case .github: .github 1202 + } 1203 + return .send(.settings(.setSelection(settingsSection))) 728 1204 } 729 1205 }
+62
supacode/Features/App/Reducer/DeeplinkInputConfirmationFeature.swift
··· 1 + import ComposableArchitecture 2 + import Foundation 3 + 4 + /// Message shown in the deeplink confirmation dialog. 5 + enum DeeplinkConfirmationMessage: Equatable, Sendable { 6 + /// A literal command that will be executed in the terminal. 7 + case command(String) 8 + /// A descriptive confirmation prompt for destructive actions. 9 + case confirmation(String) 10 + 11 + var text: String { 12 + switch self { 13 + case .command(let value), .confirmation(let value): 14 + return value 15 + } 16 + } 17 + } 18 + 19 + @Reducer 20 + struct DeeplinkInputConfirmationFeature { 21 + @ObservableState 22 + struct State: Equatable { 23 + let worktreeID: Worktree.ID 24 + let worktreeName: String 25 + let repositoryName: String? 26 + /// Display text shown to the user. 27 + let message: DeeplinkConfirmationMessage 28 + let action: Deeplink.WorktreeAction 29 + var alwaysAllow: Bool = false 30 + } 31 + 32 + enum Action: BindableAction, Equatable { 33 + case binding(BindingAction<State>) 34 + case runTapped 35 + case cancelTapped 36 + case delegate(Delegate) 37 + } 38 + 39 + @CasePathable 40 + enum Delegate: Equatable { 41 + case confirm(worktreeID: Worktree.ID, action: Deeplink.WorktreeAction, alwaysAllow: Bool) 42 + case cancel 43 + } 44 + 45 + var body: some Reducer<State, Action> { 46 + BindingReducer() 47 + Reduce { state, action in 48 + switch action { 49 + case .binding: 50 + return .none 51 + case .runTapped: 52 + return .send( 53 + .delegate(.confirm(worktreeID: state.worktreeID, action: state.action, alwaysAllow: state.alwaysAllow)) 54 + ) 55 + case .cancelTapped: 56 + return .send(.delegate(.cancel)) 57 + case .delegate: 58 + return .none 59 + } 60 + } 61 + } 62 + }
+64
supacode/Features/App/Views/DeeplinkInputConfirmationView.swift
··· 1 + import ComposableArchitecture 2 + import SwiftUI 3 + 4 + struct DeeplinkInputConfirmationView: View { 5 + @Bindable var store: StoreOf<DeeplinkInputConfirmationFeature> 6 + 7 + var body: some View { 8 + Form { 9 + Section { 10 + MessageView(message: store.message) 11 + .frame(maxWidth: .infinity, alignment: .topLeading) 12 + .fixedSize(horizontal: false, vertical: true) 13 + } header: { 14 + Text("Run Deeplink Command") 15 + Text(subtitle) 16 + } footer: { 17 + Toggle(isOn: $store.alwaysAllow) { 18 + Text("Always allow deeplink commands without confirmation.") 19 + } 20 + .help( 21 + "When enabled, deeplinks can run commands and perform destructive actions without asking for confirmation.") 22 + } 23 + .headerProminence(.increased) 24 + } 25 + .formStyle(.grouped) 26 + .scrollBounceBehavior(.basedOnSize) 27 + .safeAreaInset(edge: .bottom, spacing: 0) { 28 + HStack { 29 + Spacer() 30 + Button("Cancel") { 31 + store.send(.cancelTapped) 32 + } 33 + .keyboardShortcut(.cancelAction) 34 + .help("Cancel (Esc)") 35 + Button("Run Command") { 36 + store.send(.runTapped) 37 + } 38 + .keyboardShortcut(.defaultAction) 39 + .help("Run Command (↩)") 40 + } 41 + .padding(.horizontal, 20) 42 + .padding(.bottom, 20) 43 + } 44 + .frame(minWidth: 420) 45 + } 46 + 47 + private var subtitle: LocalizedStringKey { 48 + guard let repoName = store.repositoryName else { return "Run command in `\(store.worktreeName)`." } 49 + return "Run command in `\(store.worktreeName)` from `\(repoName)`." 50 + } 51 + } 52 + 53 + private struct MessageView: View { 54 + let message: DeeplinkConfirmationMessage 55 + 56 + var body: some View { 57 + switch message { 58 + case .command(let text): 59 + Text(text).monospaced() 60 + case .confirmation(let text): 61 + Text(text) 62 + } 63 + } 64 + }
+1 -1
supacode/Features/Settings/BusinessLogic/ClaudeHookSettings.swift
··· 31 31 let hooks: [String: [AgentHookGroup]] = [ 32 32 "UserPromptSubmit": [ 33 33 .init(hooks: [ 34 - .init(command: ClaudeHookSettings.busyOn, timeout: 10), 34 + .init(command: ClaudeHookSettings.busyOn, timeout: 10) 35 35 ]), 36 36 ], 37 37 "Stop": [
+1 -1
supacode/Features/Settings/BusinessLogic/CodexHookSettings.swift
··· 32 32 let hooks: [String: [AgentHookGroup]] = [ 33 33 "UserPromptSubmit": [ 34 34 .init(hooks: [ 35 - .init(command: CodexHookSettings.busyOn, timeout: 10), 35 + .init(command: CodexHookSettings.busyOn, timeout: 10) 36 36 ]), 37 37 ], 38 38 "Stop": [
+7
supacode/Features/Settings/Models/GlobalSettings.swift
··· 51 51 var terminalThemeSyncEnabled: Bool 52 52 var restoreTerminalLayoutEnabled: Bool 53 53 var hideSingleTabBar: Bool 54 + var allowArbitraryDeeplinkInput: Bool 54 55 var autoDeleteArchivedWorktreesAfterDays: AutoDeletePeriod? 55 56 var shortcutOverrides: [AppShortcutID: AppShortcutOverride] 56 57 ··· 78 79 terminalThemeSyncEnabled: false, 79 80 restoreTerminalLayoutEnabled: false, 80 81 hideSingleTabBar: false, 82 + allowArbitraryDeeplinkInput: false, 81 83 defaultWorktreeBaseDirectoryPath: nil, 82 84 autoDeleteArchivedWorktreesAfterDays: nil, 83 85 shortcutOverrides: [:] ··· 107 109 terminalThemeSyncEnabled: Bool = false, 108 110 restoreTerminalLayoutEnabled: Bool = false, 109 111 hideSingleTabBar: Bool = false, 112 + allowArbitraryDeeplinkInput: Bool = false, 110 113 defaultWorktreeBaseDirectoryPath: String? = nil, 111 114 autoDeleteArchivedWorktreesAfterDays: AutoDeletePeriod? = nil, 112 115 shortcutOverrides: [AppShortcutID: AppShortcutOverride] = [:] ··· 134 137 self.terminalThemeSyncEnabled = terminalThemeSyncEnabled 135 138 self.restoreTerminalLayoutEnabled = restoreTerminalLayoutEnabled 136 139 self.hideSingleTabBar = hideSingleTabBar 140 + self.allowArbitraryDeeplinkInput = allowArbitraryDeeplinkInput 137 141 self.defaultWorktreeBaseDirectoryPath = defaultWorktreeBaseDirectoryPath 138 142 self.autoDeleteArchivedWorktreesAfterDays = autoDeleteArchivedWorktreesAfterDays 139 143 self.shortcutOverrides = shortcutOverrides ··· 227 231 hideSingleTabBar = 228 232 try container.decodeIfPresent(Bool.self, forKey: .hideSingleTabBar) 229 233 ?? Self.default.hideSingleTabBar 234 + allowArbitraryDeeplinkInput = 235 + try container.decodeIfPresent(Bool.self, forKey: .allowArbitraryDeeplinkInput) 236 + ?? Self.default.allowArbitraryDeeplinkInput 230 237 defaultWorktreeBaseDirectoryPath = 231 238 try container.decodeIfPresent(String.self, forKey: .defaultWorktreeBaseDirectoryPath) 232 239 ?? Self.default.defaultWorktreeBaseDirectoryPath
+9
supacode/Features/Settings/Reducer/SettingsFeature.swift
··· 29 29 var terminalThemeSyncEnabled: Bool 30 30 var restoreTerminalLayoutEnabled: Bool 31 31 var hideSingleTabBar: Bool 32 + var allowArbitraryDeeplinkInput: Bool 32 33 var defaultWorktreeBaseDirectoryPath: String 33 34 var autoDeleteArchivedWorktreesAfterDays: AutoDeletePeriod? 34 35 var shortcutOverrides: [AppShortcutID: AppShortcutOverride] ··· 68 69 terminalThemeSyncEnabled = settings.terminalThemeSyncEnabled 69 70 restoreTerminalLayoutEnabled = settings.restoreTerminalLayoutEnabled 70 71 hideSingleTabBar = settings.hideSingleTabBar 72 + allowArbitraryDeeplinkInput = settings.allowArbitraryDeeplinkInput 71 73 autoDeleteArchivedWorktreesAfterDays = settings.autoDeleteArchivedWorktreesAfterDays 72 74 shortcutOverrides = settings.shortcutOverrides 73 75 defaultWorktreeBaseDirectoryPath = ··· 99 101 terminalThemeSyncEnabled: terminalThemeSyncEnabled, 100 102 restoreTerminalLayoutEnabled: restoreTerminalLayoutEnabled, 101 103 hideSingleTabBar: hideSingleTabBar, 104 + allowArbitraryDeeplinkInput: allowArbitraryDeeplinkInput, 102 105 defaultWorktreeBaseDirectoryPath: SupacodePaths.normalizedWorktreeBaseDirectoryPath( 103 106 defaultWorktreeBaseDirectoryPath 104 107 ), ··· 114 117 case repositoriesChanged(IdentifiedArrayOf<Repository>) 115 118 case setSelection(SettingsSection?) 116 119 case setSystemNotificationsEnabled(Bool) 120 + case setAllowArbitraryDeeplinkInput(Bool) 117 121 case showNotificationPermissionAlert(errorMessage: String?) 118 122 case updateShortcut(id: AppShortcutID, override: AppShortcutOverride?) 119 123 case toggleShortcutEnabled(id: AppShortcutID, enabled: Bool) ··· 223 227 224 228 case .setSystemNotificationsEnabled(let isEnabled): 225 229 state.systemNotificationsEnabled = isEnabled 230 + state.syncGlobalDefaults(from: state.globalSettings) 231 + return persist(state) 232 + 233 + case .setAllowArbitraryDeeplinkInput(let isEnabled): 234 + state.allowArbitraryDeeplinkInput = isEnabled 226 235 state.syncGlobalDefaults(from: state.globalSettings) 227 236 return persist(state) 228 237
+4
supacode/Features/Settings/Views/AppearanceSettingsView.swift
··· 81 81 Text("Hide Tab Bar for Single Tab") 82 82 Text("Automatically hides the tab bar when only one tab is open.") 83 83 } 84 + Toggle(isOn: $store.allowArbitraryDeeplinkInput) { 85 + Text("Allow Arbitrary Deeplink Actions") 86 + Text("Skip the confirmation dialog when a deeplink runs a command or performs a destructive action.") 87 + } 84 88 } 85 89 } 86 90 .formStyle(.grouped)
+67 -12
supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift
··· 68 68 handleManagementCommand(command) 69 69 } 70 70 71 + // swiftlint:disable:next cyclomatic_complexity 71 72 private func handleTabCommand(_ command: TerminalClient.Command) -> Bool { 72 73 switch command { 73 - case .createTab(let worktree, let runSetupScriptIfNew): 74 - Task { createTabAsync(in: worktree, runSetupScriptIfNew: runSetupScriptIfNew) } 75 - case .createTabWithInput(let worktree, let input, let runSetupScriptIfNew): 74 + case .createTab(let worktree, let runSetupScriptIfNew, let id): 75 + Task { createTabAsync(in: worktree, runSetupScriptIfNew: runSetupScriptIfNew, tabID: id) } 76 + case .createTabWithInput(let worktree, let input, let runSetupScriptIfNew, let id): 76 77 Task { 77 - createTabAsync(in: worktree, runSetupScriptIfNew: runSetupScriptIfNew, initialInput: input) 78 + createTabAsync(in: worktree, runSetupScriptIfNew: runSetupScriptIfNew, initialInput: input, tabID: id) 78 79 } 79 80 case .ensureInitialTab(let worktree, let runSetupScriptIfNew, let focusing): 80 81 let state = state(for: worktree) { runSetupScriptIfNew } 81 82 state.ensureInitialTab(focusing: focusing) 82 83 case .stopRunScript(let worktree): 83 84 _ = state(for: worktree).stopRunScript() 84 - case .selectTab(let worktree, let tabId): 85 - state(for: worktree).selectTab(tabId) 86 85 case .runBlockingScript(let worktree, let kind, let script): 87 86 _ = state(for: worktree).runBlockingScript(kind: kind, script) 88 87 case .closeFocusedTab(let worktree): 89 88 _ = closeFocusedTab(in: worktree) 90 89 case .closeFocusedSurface(let worktree): 91 90 _ = closeFocusedSurface(in: worktree) 91 + case .selectTab(let worktree, let tabID): 92 + state(for: worktree).selectTab(tabID) 93 + case .focusSurface(let worktree, let tabID, let surfaceID, let input): 94 + let terminal = state(for: worktree) 95 + terminal.selectTab(tabID) 96 + guard terminal.focusSurface(id: surfaceID) else { 97 + terminalLogger.warning("focusSurface: surface \(surfaceID) not found in worktree \(worktree.id).") 98 + break 99 + } 100 + if let input, !input.isEmpty { 101 + terminal.focusAndInsertText(input + "\r") 102 + } 103 + case .splitSurface(let worktree, let tabID, let surfaceID, let direction, let input, let id): 104 + let terminal = state(for: worktree) 105 + terminal.selectTab(tabID) 106 + let ghosttyDirection: GhosttySplitAction.NewDirection = direction == .vertical ? .down : .right 107 + let splitSucceeded = terminal.performSplitAction( 108 + .newSplit(direction: ghosttyDirection), for: surfaceID, newSurfaceID: id) 109 + guard splitSucceeded else { 110 + terminalLogger.warning("splitSurface: failed for surface \(surfaceID) in worktree \(worktree.id).") 111 + break 112 + } 113 + guard let input, !input.isEmpty else { break } 114 + terminal.focusAndInsertText(input + "\r") 115 + case .destroyTab(let worktree, let tabID): 116 + let terminal = state(for: worktree) 117 + guard terminal.tabManager.tabs.contains(where: { $0.id == tabID }) else { 118 + terminalLogger.warning("destroyTab: tab \(tabID.rawValue) not found in worktree \(worktree.id).") 119 + break 120 + } 121 + terminal.closeTab(tabID) 122 + case .destroySurface(let worktree, let tabID, let surfaceID): 123 + let terminal = state(for: worktree) 124 + terminal.selectTab(tabID) 125 + if !terminal.closeSurface(id: surfaceID) { 126 + terminalLogger.warning("destroySurface: surface \(surfaceID) not found in worktree \(worktree.id).") 127 + } 92 128 default: 93 129 return false 94 130 } ··· 107 143 state(for: worktree).navigateSearchOnFocusedSurface(.previous) 108 144 case .endSearch(let worktree): 109 145 state(for: worktree).performBindingActionOnFocusedSurface("end_search") 110 - default: 146 + case .createTab, .createTabWithInput, .ensureInitialTab, .stopRunScript, .runBlockingScript, 147 + .closeFocusedTab, .closeFocusedSurface, .performBindingAction, .selectTab, .focusSurface, 148 + .splitSurface, .destroyTab, .destroySurface, .prune, .setNotificationsEnabled, 149 + .setSelectedWorktreeID, .refreshTabBarVisibility: 111 150 return false 112 151 } 113 152 return true ··· 117 156 switch command { 118 157 case .performBindingAction(let worktree, let action): 119 158 state(for: worktree).performBindingActionOnFocusedSurface(action) 120 - default: 159 + case .createTab, .createTabWithInput, .ensureInitialTab, .stopRunScript, .runBlockingScript, 160 + .closeFocusedTab, .closeFocusedSurface, .startSearch, .searchSelection, .navigateSearchNext, 161 + .navigateSearchPrevious, .endSearch, .selectTab, .focusSurface, .splitSurface, .destroyTab, 162 + .destroySurface, .prune, .setNotificationsEnabled, .setSelectedWorktreeID, 163 + .refreshTabBarVisibility: 121 164 return false 122 165 } 123 166 return true ··· 141 184 } 142 185 selectedWorktreeID = id 143 186 terminalLogger.info("Selected worktree \(id ?? "nil")") 144 - default: 145 - return 187 + case .createTab, .createTabWithInput, .ensureInitialTab, .stopRunScript, .runBlockingScript, 188 + .closeFocusedTab, .closeFocusedSurface, .performBindingAction, .startSearch, .searchSelection, 189 + .navigateSearchNext, .navigateSearchPrevious, .endSearch, .selectTab, .focusSurface, 190 + .splitSurface, .destroyTab, .destroySurface: 191 + assertionFailure("Unhandled terminal command reached management handler: \(command)") 146 192 } 147 193 } 148 194 ··· 232 278 private func createTabAsync( 233 279 in worktree: Worktree, 234 280 runSetupScriptIfNew: Bool, 235 - initialInput: String? = nil 281 + initialInput: String? = nil, 282 + tabID: UUID? = nil 236 283 ) { 237 284 let state = state(for: worktree) { runSetupScriptIfNew } 238 285 let setupScript: String? ··· 243 290 } else { 244 291 setupScript = nil 245 292 } 246 - _ = state.createTab(setupScript: setupScript, initialInput: initialInput) 293 + _ = state.createTab(setupScript: setupScript, initialInput: initialInput, tabID: tabID) 247 294 } 248 295 249 296 @discardableResult ··· 272 319 } 273 320 states = states.filter { worktreeIDs.contains($0.key) } 274 321 emitNotificationIndicatorCountIfNeeded() 322 + } 323 + 324 + func tabExists(worktreeID: Worktree.ID, tabID: TerminalTabID) -> Bool { 325 + states[worktreeID]?.hasTab(tabID) ?? false 326 + } 327 + 328 + func surfaceExists(worktreeID: Worktree.ID, tabID: TerminalTabID, surfaceID: UUID) -> Bool { 329 + states[worktreeID]?.hasSurface(surfaceID, in: tabID) ?? false 275 330 } 276 331 277 332 func stateIfExists(for worktreeID: Worktree.ID) -> WorktreeTerminalState? {
+2 -4
supacode/Features/Terminal/Models/TerminalLayoutSnapshot.swift
··· 5 5 let selectedTabIndex: Int 6 6 7 7 struct TabSnapshot: Codable, Equatable, Sendable { 8 + let id: UUID? 8 9 let title: String 9 10 let icon: String? 10 11 let tintColor: TerminalTabTintColor? ··· 25 26 } 26 27 27 28 struct SurfaceSnapshot: Codable, Equatable, Sendable { 29 + let id: UUID? 28 30 let workingDirectory: String? 29 31 } 30 32 31 - enum SplitDirection: String, Codable, Equatable, Sendable { 32 - case horizontal 33 - case vertical 34 - } 35 33 } 36 34 37 35 extension TerminalLayoutSnapshot.LayoutNode {
+18 -2
supacode/Features/Terminal/Models/TerminalTabManager.swift
··· 1 + import Foundation 1 2 import Observation 2 3 3 4 @MainActor ··· 6 7 var tabs: [TerminalTabItem] = [] 7 8 var selectedTabId: TerminalTabID? 8 9 10 + private static let logger = SupaLogger("TabManager") 11 + 9 12 func createTab( 10 13 title: String, 11 14 icon: String?, 12 15 isTitleLocked: Bool = false, 13 - tintColor: TerminalTabTintColor? = nil 16 + tintColor: TerminalTabTintColor? = nil, 17 + id: UUID? = nil 14 18 ) -> TerminalTabID { 15 - let tab = TerminalTabItem(title: title, icon: icon, isTitleLocked: isTitleLocked, tintColor: tintColor) 19 + let tabID: TerminalTabID 20 + if let id { 21 + let candidate = TerminalTabID(rawValue: id) 22 + if tabs.contains(where: { $0.id == candidate }) { 23 + Self.logger.warning("Duplicate tab ID \(id), generating a new one.") 24 + tabID = TerminalTabID() 25 + } else { 26 + tabID = candidate 27 + } 28 + } else { 29 + tabID = TerminalTabID() 30 + } 31 + let tab = TerminalTabItem(id: tabID, title: title, icon: icon, isTitleLocked: isTitleLocked, tintColor: tintColor) 16 32 if let selectedTabId, 17 33 let selectedIndex = tabs.firstIndex(where: { $0.id == selectedTabId }) 18 34 {
+97 -58
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 26 26 private var trees: [TerminalTabID: SplitTree<GhosttySurfaceView>] = [:] 27 27 private var surfaces: [UUID: GhosttySurfaceView] = [:] 28 28 private var focusedSurfaceIdByTab: [TerminalTabID: UUID] = [:] 29 - var tabIsRunningById: [TerminalTabID: Bool] = [:] 29 + private var tabIsRunningById: [TerminalTabID: Bool] = [:] 30 30 var socketPath: String? 31 31 private(set) var shouldHideTabBar = false 32 32 private var blockingScripts: [TerminalTabID: BlockingScriptKind] = [:] ··· 138 138 focusing: Bool = true, 139 139 setupScript: String? = nil, 140 140 initialInput: String? = nil, 141 - inheritingFromSurfaceId: UUID? = nil 141 + inheritingFromSurfaceId: UUID? = nil, 142 + tabID: UUID? = nil 142 143 ) -> TerminalTabID? { 143 144 let context: ghostty_surface_context_e = 144 145 tabManager.tabs.isEmpty ··· 172 173 initialInput: resolvedInput, 173 174 focusing: focusing, 174 175 inheritingFromSurfaceId: resolvedInheritanceSurfaceId, 175 - context: context 176 + context: context, 177 + tabID: tabID, 176 178 ) 177 179 ) 178 180 if shouldConsumeSetupScript, tabId != nil { ··· 219 221 initialInput: launch.commandInput, 220 222 focusing: true, 221 223 inheritingFromSurfaceId: currentFocusedSurfaceId(), 222 - context: GHOSTTY_SURFACE_CONTEXT_TAB 224 + context: GHOSTTY_SURFACE_CONTEXT_TAB, 225 + tabID: nil, 223 226 ) 224 227 ) 225 228 guard let tabId else { ··· 248 251 let focusing: Bool 249 252 let inheritingFromSurfaceId: UUID? 250 253 let context: ghostty_surface_context_e 254 + let tabID: UUID? 251 255 } 252 256 253 257 private func createTab(_ creation: TabCreation) -> TerminalTabID? { ··· 255 259 title: creation.title, 256 260 icon: creation.icon, 257 261 isTitleLocked: creation.isTitleLocked, 258 - tintColor: creation.tintColor 262 + tintColor: creation.tintColor, 263 + id: creation.tabID, 259 264 ) 260 265 let tree = splitTree( 261 266 for: tabId, ··· 273 278 return tabId 274 279 } 275 280 281 + func hasTab(_ tabId: TerminalTabID) -> Bool { 282 + tabManager.tabs.contains(where: { $0.id == tabId }) 283 + } 284 + 285 + func hasSurface(_ surfaceId: UUID, in tabId: TerminalTabID) -> Bool { 286 + guard let tree = trees[tabId] else { return false } 287 + return tree.find(id: surfaceId) != nil 288 + } 289 + 276 290 func selectTab(_ tabId: TerminalTabID) { 277 291 guard tabManager.tabs.contains(where: { $0.id == tabId }) else { 278 - blockingScriptLogger.warning("selectTab: tab \(tabId.rawValue) not found in worktree \(worktree.id)") 292 + terminalStateLogger.warning("selectTab: tab \(tabId.rawValue) not found in worktree \(worktree.id).") 279 293 return 280 294 } 281 295 tabManager.selectTab(tabId) ··· 303 317 guard let tabId = tabManager.selectedTabId, 304 318 let focusedId = focusedSurfaceIdByTab[tabId], 305 319 let surface = surfaces[focusedId] 306 - else { return } 320 + else { 321 + terminalStateLogger.warning("focusAndInsertText: no focused surface") 322 + return 323 + } 324 + terminalStateLogger.info("focusAndInsertText: sending \(text.count) chars to surface \(focusedId)") 307 325 surface.requestFocus() 308 - surface.insertText(text, replacementRange: NSRange(location: 0, length: 0)) 326 + surface.sendText(text) 309 327 } 310 328 311 329 func syncFocus(windowIsKey: Bool, windowIsVisible: Bool) { ··· 360 378 guard let tabId = tabId(containing: id), 361 379 let surface = surfaces[id] 362 380 else { 381 + terminalStateLogger.warning("focusSurface: surface \(id) not found in worktree \(worktree.id).") 363 382 return false 364 383 } 365 384 tabManager.selectTab(tabId) ··· 387 406 } 388 407 389 408 @discardableResult 409 + func closeSurface(id surfaceID: UUID) -> Bool { 410 + guard let surface = surfaces[surfaceID] else { 411 + terminalStateLogger.warning( 412 + "closeSurface: surface \(surfaceID) not found. Known: \(surfaces.keys.map(\.uuidString))") 413 + return false 414 + } 415 + surface.performBindingAction("close_surface") 416 + return true 417 + } 418 + 419 + @discardableResult 390 420 func performBindingActionOnFocusedSurface(_ action: String) -> Bool { 391 421 guard let tabId = tabManager.selectedTabId, 392 422 let focusedId = focusedSurfaceIdByTab[tabId], ··· 479 509 return tree 480 510 } 481 511 482 - func performSplitAction(_ action: GhosttySplitAction, for surfaceId: UUID) -> Bool { 512 + func performSplitAction( 513 + _ action: GhosttySplitAction, 514 + for surfaceId: UUID, 515 + newSurfaceID: UUID? = nil 516 + ) -> Bool { 483 517 guard let tabId = tabId(containing: surfaceId), var tree = trees[tabId] else { 484 518 return false 485 519 } ··· 492 526 tabId: tabId, 493 527 initialInput: nil, 494 528 inheritingFromSurfaceId: surfaceId, 495 - context: GHOSTTY_SURFACE_CONTEXT_SPLIT 529 + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, 530 + surfaceID: newSurfaceID, 496 531 ) 497 532 do { 498 533 let newTree = try tree.inserting( ··· 504 539 focusSurface(newSurface, in: tabId) 505 540 return true 506 541 } catch { 542 + terminalStateLogger.warning( 543 + "performSplitAction: failed to insert split for surface \(surfaceId) in tab \(tabId.rawValue): \(error)") 507 544 newSurface.closeSurface() 508 545 surfaces.removeValue(forKey: newSurface.id) 509 546 return false ··· 675 712 let isBlockingScriptTab = tab.isTitleLocked || tab.tintColor != nil 676 713 tabSnapshots.append( 677 714 TerminalLayoutSnapshot.TabSnapshot( 715 + id: tab.id.rawValue, 678 716 title: tab.title, 679 717 icon: isBlockingScriptTab ? nil : tab.icon, 680 718 tintColor: isBlockingScriptTab ? nil : tab.tintColor, 681 719 layout: layout, 682 - focusedLeafIndex: focusedLeafIndex 720 + focusedLeafIndex: focusedLeafIndex, 683 721 ) 684 722 ) 685 723 } ··· 697 735 switch node { 698 736 case .leaf(let view): 699 737 return .leaf( 700 - TerminalLayoutSnapshot.SurfaceSnapshot(workingDirectory: view.bridge.state.pwd) 738 + TerminalLayoutSnapshot.SurfaceSnapshot(id: view.id, workingDirectory: view.bridge.state.pwd) 701 739 ) 702 740 case .split(let split): 703 - let direction: TerminalLayoutSnapshot.SplitDirection = 741 + let direction: SplitDirection = 704 742 switch split.direction { 705 743 case .horizontal: .horizontal 706 744 case .vertical: .vertical ··· 734 772 title: tabSnapshot.title, 735 773 icon: tabSnapshot.icon, 736 774 isTitleLocked: false, 737 - tintColor: tabSnapshot.tintColor 775 + tintColor: tabSnapshot.tintColor, 776 + id: tabSnapshot.id, 738 777 ) 739 778 let surface = createSurface( 740 779 tabId: tabId, 741 780 initialInput: nil, 742 781 workingDirectoryOverride: workingDir, 743 782 inheritingFromSurfaceId: nil, 744 - context: context 783 + context: context, 784 + surfaceID: tabSnapshot.layout.firstLeaf.id, 745 785 ) 746 786 let tree = SplitTree(view: surface) 747 787 trees[tabId] = tree ··· 799 839 direction: direction, 800 840 ratio: split.ratio, 801 841 workingDirectory: rightWorkingDir, 802 - tabId: tabId 842 + tabId: tabId, 843 + surfaceID: split.right.firstLeaf.id, 803 844 ) 804 845 else { 805 846 layoutLogger.warning("Skipping subtree restoration for tab \(tabId.rawValue)") ··· 816 857 direction: SplitTree<GhosttySurfaceView>.NewDirection, 817 858 ratio: Double, 818 859 workingDirectory: URL?, 819 - tabId: TerminalTabID 860 + tabId: TerminalTabID, 861 + surfaceID: UUID? = nil 820 862 ) -> GhosttySurfaceView? { 821 863 guard var tree = trees[tabId] else { return nil } 822 864 let newSurface = createSurface( ··· 824 866 initialInput: nil, 825 867 workingDirectoryOverride: workingDirectory, 826 868 inheritingFromSurfaceId: anchor.id, 827 - context: GHOSTTY_SURFACE_CONTEXT_SPLIT 869 + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, 870 + surfaceID: surfaceID, 828 871 ) 829 872 do { 830 873 tree = try tree.inserting(view: newSurface, at: anchor, direction: direction, ratio: ratio) ··· 857 900 } 858 901 859 902 private func formatCommandInput(_ script: String) -> String? { 860 - makeCommandInput( 861 - script: script, 862 - environmentExportPrefix: worktree.scriptEnvironmentExportPrefix 863 - ) 903 + makeCommandInput(script: script) 864 904 } 865 905 866 906 private func cleanupBlockingScriptLaunchDirectory(for tabId: TerminalTabID) { ··· 886 926 } 887 927 } 888 928 889 - // The typed command stays shell-portable by invoking a generated wrapper file, 890 - // which reads env/script metadata from sibling files rather than serializing 891 - // the user script into a shell-escaped `-c` string. 929 + // The typed command stays shell-portable by invoking a generated wrapper file 930 + // that reads the shell path from a sibling file and launches the user script, 931 + // rather than serializing it into a shell-escaped `-c` string. 892 932 private func blockingScriptLaunch(_ script: String) throws -> BlockingScriptLaunch? { 893 933 try makeBlockingScriptLaunch( 894 934 script: script, 895 - environment: worktree.scriptEnvironment, 896 935 shellPath: defaultShellPath() 897 936 ) 898 937 } ··· 945 984 946 985 private func surfaceEnvironment(tabId: TerminalTabID, surfaceID: UUID) -> [String: String] { 947 986 var env = worktree.scriptEnvironment 948 - env["SUPACODE_WORKTREE_ID"] = 949 - worktree.id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) 950 - ?? worktree.id 987 + let percentEncodingSet = CharacterSet.urlPathAllowed.subtracting(.init(charactersIn: "/")) 988 + let repoPath = worktree.repositoryRootURL.path(percentEncoded: false) 989 + env["SUPACODE_REPO_ID"] = percentEncode(repoPath, allowedCharacters: percentEncodingSet, label: "SUPACODE_REPO_ID") 990 + env["SUPACODE_WORKTREE_ID"] = percentEncode( 991 + worktree.id, allowedCharacters: percentEncodingSet, label: "SUPACODE_WORKTREE_ID") 951 992 env["SUPACODE_TAB_ID"] = tabId.rawValue.uuidString 952 993 env["SUPACODE_SURFACE_ID"] = surfaceID.uuidString 953 994 if let socketPath { ··· 956 997 return env 957 998 } 958 999 1000 + private func percentEncode(_ value: String, allowedCharacters: CharacterSet, label: String) -> String { 1001 + guard let encoded = value.addingPercentEncoding(withAllowedCharacters: allowedCharacters) else { 1002 + terminalStateLogger.warning( 1003 + "Failed to percent-encode \(label): \(value). Downstream deeplinks using this value may be malformed.") 1004 + return value 1005 + } 1006 + return encoded 1007 + } 1008 + 959 1009 private func createSurface( 960 1010 tabId: TerminalTabID, 961 1011 command: String? = nil, 962 1012 initialInput: String?, 963 1013 workingDirectoryOverride: URL? = nil, 964 1014 inheritingFromSurfaceId: UUID?, 965 - context: ghostty_surface_context_e 1015 + context: ghostty_surface_context_e, 1016 + surfaceID: UUID? = nil 966 1017 ) -> GhosttySurfaceView { 967 - let surfaceID = UUID() 1018 + let resolvedID: UUID 1019 + if let requested = surfaceID { 1020 + if surfaces[requested] != nil { 1021 + terminalStateLogger.warning("Duplicate surface ID \(requested), generating a new one.") 1022 + resolvedID = UUID() 1023 + } else { 1024 + resolvedID = requested 1025 + } 1026 + } else { 1027 + resolvedID = UUID() 1028 + } 1029 + let surfaceID = resolvedID 1030 + terminalStateLogger.info("createSurface: resolved=\(surfaceID)") 968 1031 let inherited = inheritedSurfaceConfig(fromSurfaceId: inheritingFromSurfaceId, context: context) 969 1032 let view = GhosttySurfaceView( 970 1033 id: surfaceID, ··· 1412 1475 } 1413 1476 1414 1477 nonisolated func makeCommandInput( 1415 - script: String, 1416 - environmentExportPrefix: String 1478 + script: String 1417 1479 ) -> String? { 1418 1480 let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) 1419 1481 guard !trimmed.isEmpty else { return nil } 1420 - return environmentExportPrefix + trimmed + "\n" 1482 + return trimmed + "\n" 1421 1483 } 1422 1484 1423 1485 nonisolated struct BlockingScriptLaunch { 1424 1486 let directoryURL: URL 1425 1487 let runnerURL: URL 1426 1488 let scriptURL: URL 1427 - let rootPathURL: URL 1428 - let worktreePathURL: URL 1429 1489 let shellPathURL: URL 1430 1490 let commandInput: String 1431 1491 } 1432 1492 1433 1493 nonisolated func makeBlockingScriptLaunch( 1434 1494 script: String, 1435 - environment: [String: String], 1436 1495 shellPath: String, 1437 1496 baseDirectoryURL: URL = FileManager.default.temporaryDirectory 1438 1497 ) throws -> BlockingScriptLaunch? { 1439 1498 let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) 1440 - guard !trimmed.isEmpty, 1441 - let rootPath = environment["SUPACODE_ROOT_PATH"], 1442 - let worktreePath = environment["SUPACODE_WORKTREE_PATH"] 1443 - else { 1444 - return nil 1445 - } 1499 + guard !trimmed.isEmpty else { return nil } 1446 1500 1447 1501 let fileManager = FileManager.default 1448 1502 let directoryURL = baseDirectoryURL.appending( ··· 1451 1505 ) 1452 1506 let runnerURL = directoryURL.appending(path: "run", directoryHint: .notDirectory) 1453 1507 let scriptURL = directoryURL.appending(path: "script", directoryHint: .notDirectory) 1454 - let rootPathURL = directoryURL.appending(path: "root-path", directoryHint: .notDirectory) 1455 - let worktreePathURL = directoryURL.appending(path: "worktree-path", directoryHint: .notDirectory) 1456 1508 let shellPathURL = directoryURL.appending(path: "shell-path", directoryHint: .notDirectory) 1457 1509 1458 1510 do { 1459 1511 try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) 1460 1512 try Data((trimmed + "\n").utf8).write(to: scriptURL, options: [.atomic]) 1461 - try Data((rootPath + "\n").utf8).write(to: rootPathURL, options: [.atomic]) 1462 - try Data((worktreePath + "\n").utf8).write(to: worktreePathURL, options: [.atomic]) 1463 1513 try Data((shellPath + "\n").utf8).write(to: shellPathURL, options: [.atomic]) 1464 1514 try Data( 1465 1515 blockingScriptRunnerContents( 1466 1516 scriptURL: scriptURL, 1467 - rootPathURL: rootPathURL, 1468 - worktreePathURL: worktreePathURL, 1469 1517 shellPathURL: shellPathURL 1470 1518 ).utf8 1471 1519 ).write(to: runnerURL, options: [.atomic]) ··· 1482 1530 directoryURL: directoryURL, 1483 1531 runnerURL: runnerURL, 1484 1532 scriptURL: scriptURL, 1485 - rootPathURL: rootPathURL, 1486 - worktreePathURL: worktreePathURL, 1487 1533 shellPathURL: shellPathURL, 1488 1534 commandInput: shellSingleQuoted(runnerURL.path(percentEncoded: false)) + "\n" 1489 1535 ) ··· 1491 1537 1492 1538 nonisolated func blockingScriptRunnerContents( 1493 1539 scriptURL: URL, 1494 - rootPathURL: URL, 1495 - worktreePathURL: URL, 1496 1540 shellPathURL: URL 1497 1541 ) -> String { 1498 - let quotedRootPath = shellSingleQuoted(rootPathURL.path(percentEncoded: false)) 1499 - let quotedWorktreePath = shellSingleQuoted(worktreePathURL.path(percentEncoded: false)) 1500 1542 let quotedShellPath = shellSingleQuoted(shellPathURL.path(percentEncoded: false)) 1501 1543 let quotedScriptPath = shellSingleQuoted(scriptURL.path(percentEncoded: false)) 1502 1544 1503 1545 return """ 1504 1546 #!/bin/sh 1505 1547 set -eu 1506 - IFS= read -r SUPACODE_ROOT_PATH < \(quotedRootPath) 1507 - IFS= read -r SUPACODE_WORKTREE_PATH < \(quotedWorktreePath) 1508 1548 IFS= read -r SUPACODE_SHELL_PATH < \(quotedShellPath) 1509 - export SUPACODE_ROOT_PATH SUPACODE_WORKTREE_PATH 1510 1549 "$SUPACODE_SHELL_PATH" -l \(quotedScriptPath) 1511 1550 """ 1512 1551 }
+11
supacode/Info.plist
··· 43 43 <true/> 44 44 <key>SUAutomaticallyUpdate</key> 45 45 <true/> 46 + <key>CFBundleURLTypes</key> 47 + <array> 48 + <dict> 49 + <key>CFBundleURLName</key> 50 + <string>sh.supacode.deeplink</string> 51 + <key>CFBundleURLSchemes</key> 52 + <array> 53 + <string>supacode</string> 54 + </array> 55 + </dict> 56 + </array> 46 57 <key>UTExportedTypeDeclarations</key> 47 58 <array> 48 59 <dict>
+15
supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift
··· 4 4 import GhosttyKit 5 5 import QuartzCore 6 6 7 + private let surfaceLogger = SupaLogger("Surface") 8 + 7 9 final class GhosttySurfaceView: NSView, Identifiable { 8 10 private struct ScrollbarState { 9 11 let total: UInt64 ··· 1626 1628 pboard.declareTypes([.string], owner: nil) 1627 1629 pboard.setString(String(cString: text.text), forType: .string) 1628 1630 return true 1631 + } 1632 + 1633 + /// Sends raw text directly to the terminal PTY, bypassing the text input system. 1634 + func sendText(_ text: String) { 1635 + guard let surface else { 1636 + surfaceLogger.warning("sendText: surface not available, dropping \(text.count) chars.") 1637 + return 1638 + } 1639 + let len = text.utf8CString.count 1640 + guard len > 0 else { return } 1641 + text.withCString { ptr in 1642 + ghostty_surface_text(surface, ptr, UInt(len - 1)) 1643 + } 1629 1644 } 1630 1645 1631 1646 func readSelection(from pboard: NSPasteboard) -> Bool {
+1130
supacodeTests/AppFeatureDeeplinkTests.swift
··· 1 + import ComposableArchitecture 2 + import DependenciesTestSupport 3 + import Foundation 4 + import Sharing 5 + import Testing 6 + 7 + @testable import supacode 8 + 9 + @MainActor 10 + @Suite(.serialized) 11 + struct AppFeatureDeeplinkTests { 12 + // MARK: - Routing after load. 13 + 14 + @Test(.dependencies) func selectWorktreeDeeplink() async { 15 + let worktree = makeWorktree() 16 + let store = makeStore(worktree: worktree) 17 + 18 + await store.send(.deeplink(.worktree(id: worktree.id, action: .select))) 19 + await store.receive(\.repositories.selectWorktree) 20 + } 21 + 22 + @Test(.dependencies) func runWorktreeDeeplink() async { 23 + let worktree = makeWorktree() 24 + let store = makeStore(worktree: worktree) 25 + 26 + await store.send(.deeplink(.worktree(id: worktree.id, action: .run))) 27 + await store.receive(\.repositories.selectWorktree) 28 + await store.receive(\.runScript) 29 + } 30 + 31 + @Test(.dependencies) func pinWorktreeDeeplink() async { 32 + let worktree = makeWorktree() 33 + let store = makeStore(worktree: worktree) 34 + 35 + await store.send(.deeplink(.worktree(id: worktree.id, action: .pin))) 36 + await store.receive(\.repositories.pinWorktree) 37 + } 38 + 39 + @Test(.dependencies) func unpinWorktreeDeeplink() async { 40 + let worktree = makeWorktree() 41 + var repositories = makeRepositoriesState(worktree: worktree) 42 + repositories.pinnedWorktreeIDs = [worktree.id] 43 + let store = TestStore( 44 + initialState: AppFeature.State( 45 + repositories: repositories, 46 + settings: SettingsFeature.State() 47 + ) 48 + ) { 49 + AppFeature() 50 + } 51 + store.exhaustivity = .off 52 + 53 + await store.send(.deeplink(.worktree(id: worktree.id, action: .unpin))) 54 + await store.receive(\.repositories.unpinWorktree) 55 + } 56 + 57 + @Test(.dependencies) func archiveWorktreeDeeplink() async { 58 + let worktree = makeWorktree() 59 + let store = makeStore(worktree: worktree) 60 + 61 + await store.send(.deeplink(.worktree(id: worktree.id, action: .archive))) 62 + await store.receive(\.repositories.requestArchiveWorktree) 63 + } 64 + 65 + @Test(.dependencies) func archiveWorktreeDeeplinkWithUnknownIDShowsAlert() async { 66 + let worktree = makeWorktree() 67 + let store = makeStore(worktree: worktree) 68 + 69 + await store.send(.deeplink(.worktree(id: "/nonexistent", action: .archive))) 70 + #expect(store.state.alert != nil) 71 + } 72 + 73 + @Test(.dependencies) func deleteWorktreeDeeplinkShowsConfirmation() async { 74 + let worktree = makeWorktree() 75 + let store = makeStore(worktree: worktree) 76 + 77 + await store.send(.deeplink(.worktree(id: worktree.id, action: .delete))) 78 + #expect(store.state.deeplinkInputConfirmation?.message == .confirmation("Delete worktree \"wt-1\"?")) 79 + #expect(store.state.deeplinkInputConfirmation?.action == .delete) 80 + } 81 + 82 + @Test(.dependencies) func deleteWorktreeDeeplinkSkipsConfirmationWhenSettingEnabled() async { 83 + let worktree = makeWorktree() 84 + var settings = SettingsFeature.State() 85 + settings.allowArbitraryDeeplinkInput = true 86 + let store = TestStore( 87 + initialState: AppFeature.State( 88 + repositories: makeRepositoriesState(worktree: worktree), 89 + settings: settings, 90 + ) 91 + ) { 92 + AppFeature() 93 + } 94 + store.exhaustivity = .off 95 + 96 + await store.send(.deeplink(.worktree(id: worktree.id, action: .delete))) 97 + #expect(store.state.deeplinkInputConfirmation == nil) 98 + await store.receive(\.repositories.deleteWorktreeConfirmed) 99 + } 100 + 101 + @Test(.dependencies) func deleteWorktreeDeeplinkConfirmationAcceptedSendsDeleteConfirmed() async { 102 + let worktree = makeWorktree() 103 + var initialState = AppFeature.State( 104 + repositories: makeRepositoriesState(worktree: worktree), 105 + settings: SettingsFeature.State(), 106 + ) 107 + initialState.deeplinkInputConfirmation = DeeplinkInputConfirmationFeature.State( 108 + worktreeID: worktree.id, 109 + worktreeName: worktree.name, 110 + repositoryName: "repo", 111 + message: .confirmation("Delete worktree \"wt-1\"?"), 112 + action: .delete, 113 + ) 114 + let store = TestStore(initialState: initialState) { 115 + AppFeature() 116 + } 117 + store.exhaustivity = .off 118 + 119 + await withKnownIssue("TCA @Presents dismiss tracking") { 120 + await store.send( 121 + .deeplinkInputConfirmation( 122 + .presented( 123 + .delegate( 124 + .confirm(worktreeID: worktree.id, action: .delete, alwaysAllow: false))) 125 + ) 126 + ) { 127 + $0.deeplinkInputConfirmation = nil 128 + } 129 + } 130 + await store.receive(\.repositories.deleteWorktreeConfirmed) 131 + await store.finish() 132 + } 133 + 134 + @Test(.dependencies) func deleteMainWorktreeDeeplinkShowsDeleteNotAllowed() async { 135 + let mainWorktree = makeWorktree(id: "/tmp/repo", name: "repo") 136 + let store = makeStore(worktree: mainWorktree) 137 + 138 + await store.send(.deeplink(.worktree(id: mainWorktree.id, action: .delete))) 139 + #expect(store.state.deeplinkInputConfirmation == nil) 140 + #expect(store.state.alert != nil) 141 + } 142 + 143 + @Test(.dependencies) func deleteWorktreeDeeplinkWithUnknownIDShowsAlert() async { 144 + let worktree = makeWorktree() 145 + let store = makeStore(worktree: worktree) 146 + 147 + await store.send(.deeplink(.worktree(id: "/nonexistent", action: .delete))) 148 + #expect(store.state.alert != nil) 149 + } 150 + 151 + @Test(.dependencies) func unarchiveWorktreeDeeplink() async { 152 + let worktree = makeWorktree() 153 + let store = makeStore(worktree: worktree) 154 + 155 + await store.send(.deeplink(.worktree(id: worktree.id, action: .unarchive))) 156 + await store.receive(\.repositories.unarchiveWorktree) 157 + } 158 + 159 + @Test(.dependencies) func stopWorktreeDeeplink() async { 160 + let worktree = makeWorktree() 161 + let store = makeStore(worktree: worktree) 162 + 163 + await store.send(.deeplink(.worktree(id: worktree.id, action: .stop))) 164 + await store.receive(\.repositories.selectWorktree) 165 + await store.receive(\.stopRunScript) 166 + } 167 + 168 + // MARK: - Help deeplink. 169 + 170 + @Test(.dependencies) func helpDeeplinkSetsCheatsheetRequested() async { 171 + let worktree = makeWorktree() 172 + let store = makeStore(worktree: worktree) 173 + 174 + await store.send(.deeplink(.help)) { 175 + $0.isDeeplinkCheatsheetRequested = true 176 + } 177 + } 178 + 179 + @Test(.dependencies) func deeplinkCheatsheetOpenedResetsFlag() async { 180 + let worktree = makeWorktree() 181 + var initialState = AppFeature.State( 182 + repositories: makeRepositoriesState(worktree: worktree), 183 + settings: SettingsFeature.State(), 184 + ) 185 + initialState.isDeeplinkCheatsheetRequested = true 186 + let store = TestStore(initialState: initialState) { 187 + AppFeature() 188 + } 189 + store.exhaustivity = .off 190 + 191 + await store.send(.deeplinkCheatsheetOpened) { 192 + $0.isDeeplinkCheatsheetRequested = false 193 + } 194 + } 195 + 196 + // MARK: - Destructive deeplink actions. 197 + 198 + @Test(.dependencies) func tabDestroyShowsConfirmationWhenSettingDisabled() async { 199 + let worktree = makeWorktree() 200 + let tabUUID = UUID() 201 + let store = makeStore(worktree: worktree) 202 + 203 + await store.send(.deeplink(.worktree(id: worktree.id, action: .tabDestroy(tabID: tabUUID)))) 204 + #expect(store.state.deeplinkInputConfirmation != nil) 205 + } 206 + 207 + @Test(.dependencies) func tabDestroySkipsConfirmationWhenSettingEnabled() async { 208 + let worktree = makeWorktree() 209 + let tabUUID = UUID() 210 + let sent = LockIsolated<[TerminalClient.Command]>([]) 211 + var settings = SettingsFeature.State() 212 + settings.allowArbitraryDeeplinkInput = true 213 + let store = TestStore( 214 + initialState: AppFeature.State( 215 + repositories: makeRepositoriesState(worktree: worktree), 216 + settings: settings, 217 + ) 218 + ) { 219 + AppFeature() 220 + } withDependencies: { 221 + $0.terminalClient.send = { command in 222 + sent.withValue { $0.append(command) } 223 + } 224 + $0.terminalClient.tabExists = { _, _ in true } 225 + } 226 + store.exhaustivity = .off 227 + 228 + await store.send(.deeplink(.worktree(id: worktree.id, action: .tabDestroy(tabID: tabUUID)))) 229 + #expect(store.state.deeplinkInputConfirmation == nil) 230 + let hasDestroy = sent.value.contains(where: { 231 + if case .destroyTab = $0 { return true } 232 + return false 233 + }) 234 + #expect(hasDestroy) 235 + } 236 + 237 + @Test(.dependencies) func surfaceDestroyShowsConfirmationWhenSettingDisabled() async { 238 + let worktree = makeWorktree() 239 + let tabUUID = UUID() 240 + let surfaceUUID = UUID() 241 + let store = makeStore(worktree: worktree) 242 + 243 + await store.send( 244 + .deeplink(.worktree(id: worktree.id, action: .surfaceDestroy(tabID: tabUUID, surfaceID: surfaceUUID)))) 245 + #expect(store.state.deeplinkInputConfirmation != nil) 246 + } 247 + 248 + @Test(.dependencies) func surfaceDestroySkipsConfirmationWhenSettingEnabled() async { 249 + let worktree = makeWorktree() 250 + let tabUUID = UUID() 251 + let surfaceUUID = UUID() 252 + let sent = LockIsolated<[TerminalClient.Command]>([]) 253 + var settings = SettingsFeature.State() 254 + settings.allowArbitraryDeeplinkInput = true 255 + let store = TestStore( 256 + initialState: AppFeature.State( 257 + repositories: makeRepositoriesState(worktree: worktree), 258 + settings: settings, 259 + ) 260 + ) { 261 + AppFeature() 262 + } withDependencies: { 263 + $0.terminalClient.send = { command in 264 + sent.withValue { $0.append(command) } 265 + } 266 + $0.terminalClient.tabExists = { _, _ in true } 267 + $0.terminalClient.surfaceExists = { _, _, _ in true } 268 + } 269 + store.exhaustivity = .off 270 + 271 + await store.send( 272 + .deeplink(.worktree(id: worktree.id, action: .surfaceDestroy(tabID: tabUUID, surfaceID: surfaceUUID)))) 273 + #expect(store.state.deeplinkInputConfirmation == nil) 274 + let hasDestroy = sent.value.contains(where: { 275 + if case .destroySurface = $0 { return true } 276 + return false 277 + }) 278 + #expect(hasDestroy) 279 + } 280 + 281 + @Test(.dependencies) func surfaceWithInputShowsConfirmation() async { 282 + let worktree = makeWorktree() 283 + let tabUUID = UUID() 284 + let surfaceUUID = UUID() 285 + let store = makeStore(worktree: worktree) 286 + 287 + await store.send( 288 + .deeplink( 289 + .worktree( 290 + id: worktree.id, action: .surface(tabID: tabUUID, surfaceID: surfaceUUID, input: "echo test")))) 291 + #expect(store.state.deeplinkInputConfirmation != nil) 292 + #expect(store.state.deeplinkInputConfirmation?.message == .command("echo test")) 293 + } 294 + 295 + @Test(.dependencies) func surfaceSplitWithInputShowsConfirmation() async { 296 + let worktree = makeWorktree() 297 + let tabUUID = UUID() 298 + let surfaceUUID = UUID() 299 + let store = makeStore(worktree: worktree) 300 + 301 + await store.send( 302 + .deeplink( 303 + .worktree( 304 + id: worktree.id, 305 + action: .surfaceSplit( 306 + tabID: tabUUID, surfaceID: surfaceUUID, direction: .horizontal, input: "echo test", id: nil)))) 307 + #expect(store.state.deeplinkInputConfirmation != nil) 308 + #expect(store.state.deeplinkInputConfirmation?.message == .command("echo test")) 309 + } 310 + 311 + @Test(.dependencies) func surfaceSplitWithoutInputSkipsConfirmation() async { 312 + let worktree = makeWorktree() 313 + let tabUUID = UUID() 314 + let surfaceUUID = UUID() 315 + let sent = LockIsolated<[TerminalClient.Command]>([]) 316 + let store = TestStore( 317 + initialState: AppFeature.State( 318 + repositories: makeRepositoriesState(worktree: worktree), 319 + settings: SettingsFeature.State(), 320 + ) 321 + ) { 322 + AppFeature() 323 + } withDependencies: { 324 + $0.terminalClient.send = { command in 325 + sent.withValue { $0.append(command) } 326 + } 327 + $0.terminalClient.tabExists = { _, _ in true } 328 + $0.terminalClient.surfaceExists = { _, _, _ in true } 329 + } 330 + store.exhaustivity = .off 331 + 332 + await store.send( 333 + .deeplink( 334 + .worktree( 335 + id: worktree.id, 336 + action: .surfaceSplit( 337 + tabID: tabUUID, surfaceID: surfaceUUID, direction: .vertical, input: nil, id: nil)))) 338 + #expect(store.state.deeplinkInputConfirmation == nil) 339 + let hasSplit = sent.value.contains(where: { 340 + if case .splitSurface = $0 { return true } 341 + return false 342 + }) 343 + #expect(hasSplit) 344 + } 345 + 346 + @Test(.dependencies) func surfaceSplitWithInputConfirmationAcceptedSendsCommand() async { 347 + let worktree = makeWorktree() 348 + let tabUUID = UUID() 349 + let surfaceUUID = UUID() 350 + let sent = LockIsolated<[TerminalClient.Command]>([]) 351 + var initialState = AppFeature.State( 352 + repositories: makeRepositoriesState(worktree: worktree), 353 + settings: SettingsFeature.State(), 354 + ) 355 + initialState.deeplinkInputConfirmation = DeeplinkInputConfirmationFeature.State( 356 + worktreeID: worktree.id, 357 + worktreeName: worktree.name, 358 + repositoryName: "repo", 359 + message: .command("echo test"), 360 + action: .surfaceSplit( 361 + tabID: tabUUID, surfaceID: surfaceUUID, direction: .horizontal, input: "echo test", id: nil), 362 + ) 363 + let store = TestStore(initialState: initialState) { 364 + AppFeature() 365 + } withDependencies: { 366 + $0.terminalClient.send = { command in 367 + sent.withValue { $0.append(command) } 368 + } 369 + $0.terminalClient.tabExists = { _, _ in true } 370 + $0.terminalClient.surfaceExists = { _, _, _ in true } 371 + } 372 + store.exhaustivity = .off 373 + 374 + await withKnownIssue("TCA @Presents dismiss tracking") { 375 + await store.send( 376 + .deeplinkInputConfirmation( 377 + .presented( 378 + .delegate( 379 + .confirm( 380 + worktreeID: worktree.id, 381 + action: .surfaceSplit( 382 + tabID: tabUUID, surfaceID: surfaceUUID, direction: .horizontal, 383 + input: "echo test", id: nil), 384 + alwaysAllow: false))) 385 + ) 386 + ) { 387 + $0.deeplinkInputConfirmation = nil 388 + } 389 + } 390 + let hasSplit = sent.value.contains(where: { 391 + if case .splitSurface = $0 { return true } 392 + return false 393 + }) 394 + #expect(hasSplit) 395 + } 396 + 397 + @Test(.dependencies) func settingsDeeplinkOpensGeneral() async { 398 + let worktree = makeWorktree() 399 + let store = makeStore(worktree: worktree) 400 + 401 + await store.send(.deeplink(.settings(section: nil))) 402 + await store.receive(\.settings.setSelection) 403 + } 404 + 405 + @Test(.dependencies) func settingsDeeplinkOpensSpecificSection() async { 406 + let worktree = makeWorktree() 407 + let store = makeStore(worktree: worktree) 408 + 409 + await store.send(.deeplink(.settings(section: .worktrees))) 410 + await store.receive(\.settings.setSelection) 411 + } 412 + 413 + @Test(.dependencies) func settingsRepoDeeplinkOpensRepoSettings() async { 414 + let worktree = makeWorktree() 415 + let store = makeStore(worktree: worktree) 416 + 417 + await store.send(.deeplink(.settingsRepo(repositoryID: "/tmp/repo"))) 418 + await store.receive(\.settings.setSelection) 419 + } 420 + 421 + @Test(.dependencies) func settingsRepoDeeplinkWithUnknownRepoShowsAlert() async { 422 + let worktree = makeWorktree() 423 + let store = makeStore(worktree: worktree) 424 + 425 + await store.send(.deeplink(.settingsRepo(repositoryID: "/nonexistent"))) 426 + #expect(store.state.alert != nil) 427 + } 428 + 429 + @Test(.dependencies) func repoOpenDeeplink() async { 430 + let worktree = makeWorktree() 431 + let store = makeStore(worktree: worktree) 432 + 433 + await store.send(.deeplink(.repoOpen(path: URL(fileURLWithPath: "/tmp/new-repo")))) 434 + await store.receive(\.repositories.openRepositories) 435 + } 436 + 437 + @Test(.dependencies) func repoWorktreeNewWithoutBranchDeeplink() async { 438 + let worktree = makeWorktree() 439 + let store = makeStore(worktree: worktree) 440 + 441 + await store.send( 442 + .deeplink( 443 + .repoWorktreeNew( 444 + repositoryID: "/tmp/repo", 445 + branch: nil, 446 + baseRef: nil, 447 + fetchOrigin: false 448 + ) 449 + ) 450 + ) 451 + await store.receive(\.repositories.createRandomWorktreeInRepository) 452 + } 453 + 454 + // MARK: - Trailing slash normalization. 455 + 456 + @Test(.dependencies) func worktreeIDWithoutTrailingSlashMatchesWorktreeWithSlash() async { 457 + // Worktree IDs from standardizedFileURL have a trailing slash. 458 + let worktree = makeWorktree(id: "/tmp/repo/wt-1/") 459 + let store = makeStore(worktree: worktree) 460 + 461 + // Deeplink uses ID without trailing slash. 462 + await store.send(.deeplink(.worktree(id: "/tmp/repo/wt-1", action: .select))) 463 + await store.receive(\.repositories.selectWorktree) 464 + } 465 + 466 + // MARK: - Unknown worktree alert. 467 + 468 + @Test(.dependencies) func unknownWorktreeShowsAlert() async { 469 + let worktree = makeWorktree() 470 + let store = makeStore(worktree: worktree) 471 + 472 + await store.send(.deeplink(.worktree(id: "/nonexistent", action: .select))) 473 + #expect(store.state.alert != nil) 474 + } 475 + 476 + // MARK: - Tab actions. 477 + 478 + @Test(.dependencies) func worktreeTabWithValidTabID() async { 479 + let worktree = makeWorktree() 480 + let tabUUID = UUID() 481 + let sent = LockIsolated<[TerminalClient.Command]>([]) 482 + let store = TestStore( 483 + initialState: AppFeature.State( 484 + repositories: makeRepositoriesState(worktree: worktree), 485 + settings: SettingsFeature.State() 486 + ) 487 + ) { 488 + AppFeature() 489 + } withDependencies: { 490 + $0.terminalClient.send = { command in 491 + sent.withValue { $0.append(command) } 492 + } 493 + $0.terminalClient.tabExists = { _, _ in true } 494 + } 495 + store.exhaustivity = .off 496 + 497 + await store.send(.deeplink(.worktree(id: worktree.id, action: .tab(tabID: tabUUID)))) 498 + await store.receive(\.repositories.selectWorktree) 499 + let expected = TerminalClient.Command.selectTab(worktree, tabID: TerminalTabID(rawValue: tabUUID)) 500 + #expect(sent.value.contains(expected)) 501 + } 502 + 503 + // MARK: - Tab new with input confirmation. 504 + 505 + @Test(.dependencies) func tabNewWithInputShowsConfirmationSheet() async { 506 + let worktree = makeWorktree() 507 + let store = makeStore(worktree: worktree) 508 + 509 + await store.send(.deeplink(.worktree(id: worktree.id, action: .tabNew(input: "echo hello", id: nil)))) 510 + #expect(store.state.deeplinkInputConfirmation != nil) 511 + #expect(store.state.deeplinkInputConfirmation?.message == .command("echo hello")) 512 + #expect(store.state.deeplinkInputConfirmation?.worktreeID == worktree.id) 513 + } 514 + 515 + @Test(.dependencies) func tabNewWithInputSkipsConfirmationWhenSettingEnabled() async { 516 + let worktree = makeWorktree() 517 + let sent = LockIsolated<[TerminalClient.Command]>([]) 518 + var settings = SettingsFeature.State() 519 + settings.allowArbitraryDeeplinkInput = true 520 + let store = TestStore( 521 + initialState: AppFeature.State( 522 + repositories: makeRepositoriesState(worktree: worktree), 523 + settings: settings, 524 + ) 525 + ) { 526 + AppFeature() 527 + } withDependencies: { 528 + $0.terminalClient.send = { command in 529 + sent.withValue { $0.append(command) } 530 + } 531 + } 532 + store.exhaustivity = .off 533 + 534 + await store.send(.deeplink(.worktree(id: worktree.id, action: .tabNew(input: "echo hello", id: nil)))) 535 + #expect(store.state.deeplinkInputConfirmation == nil) 536 + #expect( 537 + sent.value.contains( 538 + .createTabWithInput(worktree, input: "echo hello", runSetupScriptIfNew: false, id: nil) 539 + ) 540 + ) 541 + } 542 + 543 + @Test(.dependencies) func tabNewConfirmationAcceptedSendsTerminalCommand() async { 544 + let worktree = makeWorktree() 545 + let sent = LockIsolated<[TerminalClient.Command]>([]) 546 + var initialState = AppFeature.State( 547 + repositories: makeRepositoriesState(worktree: worktree), 548 + settings: SettingsFeature.State(), 549 + ) 550 + initialState.deeplinkInputConfirmation = makeConfirmationState(worktree: worktree, input: "echo hello") 551 + let store = TestStore(initialState: initialState) { 552 + AppFeature() 553 + } withDependencies: { 554 + $0.terminalClient.send = { command in 555 + sent.withValue { $0.append(command) } 556 + } 557 + } 558 + store.exhaustivity = .off 559 + 560 + await withKnownIssue("TCA @Presents dismiss tracking") { 561 + await store.send( 562 + .deeplinkInputConfirmation( 563 + .presented( 564 + .delegate( 565 + .confirm(worktreeID: worktree.id, action: .tabNew(input: "echo hello", id: nil), alwaysAllow: false))) 566 + ) 567 + ) { 568 + $0.deeplinkInputConfirmation = nil 569 + } 570 + } 571 + #expect( 572 + sent.value.contains( 573 + .createTabWithInput(worktree, input: "echo hello", runSetupScriptIfNew: false, id: nil) 574 + ) 575 + ) 576 + await store.finish() 577 + } 578 + 579 + @Test(.dependencies) func tabNewConfirmationWithAlwaysAllowPersistsSetting() async { 580 + let worktree = makeWorktree() 581 + var initialState = AppFeature.State( 582 + repositories: makeRepositoriesState(worktree: worktree), 583 + settings: SettingsFeature.State(), 584 + ) 585 + initialState.deeplinkInputConfirmation = makeConfirmationState(worktree: worktree, input: "echo hello") 586 + let store = TestStore(initialState: initialState) { 587 + AppFeature() 588 + } withDependencies: { 589 + $0.terminalClient.send = { _ in } 590 + } 591 + store.exhaustivity = .off 592 + 593 + await withKnownIssue("TCA @Presents dismiss tracking") { 594 + await store.send( 595 + .deeplinkInputConfirmation( 596 + .presented( 597 + .delegate( 598 + .confirm(worktreeID: worktree.id, action: .tabNew(input: "echo hello", id: nil), alwaysAllow: true))) 599 + ) 600 + ) { 601 + $0.deeplinkInputConfirmation = nil 602 + } 603 + } 604 + // The setting is persisted via SettingsFeature, not mutated directly. 605 + await store.receive(\.settings.setAllowArbitraryDeeplinkInput) { 606 + $0.settings.allowArbitraryDeeplinkInput = true 607 + } 608 + await store.finish() 609 + } 610 + 611 + @Test(.dependencies) func tabNewConfirmationCancelledDoesNothing() async { 612 + let worktree = makeWorktree() 613 + let sent = LockIsolated<[TerminalClient.Command]>([]) 614 + var initialState = AppFeature.State( 615 + repositories: makeRepositoriesState(worktree: worktree), 616 + settings: SettingsFeature.State(), 617 + ) 618 + initialState.deeplinkInputConfirmation = makeConfirmationState(worktree: worktree, input: "echo hello") 619 + let store = TestStore(initialState: initialState) { 620 + AppFeature() 621 + } withDependencies: { 622 + $0.terminalClient.send = { command in 623 + sent.withValue { $0.append(command) } 624 + } 625 + } 626 + store.exhaustivity = .off 627 + 628 + await withKnownIssue("TCA @Presents dismiss tracking") { 629 + await store.send(.deeplinkInputConfirmation(.presented(.delegate(.cancel)))) { 630 + $0.deeplinkInputConfirmation = nil 631 + } 632 + } 633 + #expect(sent.value.isEmpty) 634 + } 635 + 636 + @Test(.dependencies) func tabNewConfirmationWithDeletedWorktreeDoesNothing() async { 637 + let sent = LockIsolated<[TerminalClient.Command]>([]) 638 + var initialState = AppFeature.State( 639 + repositories: RepositoriesFeature.State(), 640 + settings: SettingsFeature.State(), 641 + ) 642 + initialState.deeplinkInputConfirmation = makeConfirmationState( 643 + worktreeID: "/nonexistent", 644 + worktreeName: "unknown", 645 + repositoryName: nil, 646 + input: "echo hello", 647 + ) 648 + let store = TestStore(initialState: initialState) { 649 + AppFeature() 650 + } withDependencies: { 651 + $0.terminalClient.send = { command in 652 + sent.withValue { $0.append(command) } 653 + } 654 + } 655 + store.exhaustivity = .off 656 + 657 + await withKnownIssue("TCA @Presents dismiss tracking") { 658 + await store.send( 659 + .deeplinkInputConfirmation( 660 + .presented( 661 + .delegate( 662 + .confirm( 663 + worktreeID: "/nonexistent", action: .tabNew(input: "echo hello", id: nil), alwaysAllow: false))) 664 + ) 665 + ) { 666 + $0.deeplinkInputConfirmation = nil 667 + } 668 + } 669 + #expect(sent.value.isEmpty) 670 + } 671 + 672 + @Test(.dependencies) func tabNewWithoutInputCreatesNewTerminal() async { 673 + let worktree = makeWorktree() 674 + let sent = LockIsolated<[TerminalClient.Command]>([]) 675 + let store = TestStore( 676 + initialState: AppFeature.State( 677 + repositories: makeRepositoriesState(worktree: worktree), 678 + settings: SettingsFeature.State(), 679 + ) 680 + ) { 681 + AppFeature() 682 + } withDependencies: { 683 + $0.terminalClient.send = { command in 684 + sent.withValue { $0.append(command) } 685 + } 686 + } 687 + store.exhaustivity = .off 688 + 689 + await store.send(.deeplink(.worktree(id: worktree.id, action: .tabNew(input: nil, id: nil)))) 690 + let hasCreateTab = sent.value.contains(where: { 691 + if case .createTab(let target, _, _) = $0 { return target.id == worktree.id } 692 + return false 693 + }) 694 + #expect(hasCreateTab) 695 + } 696 + 697 + // MARK: - Queuing before load. 698 + 699 + @Test(.dependencies) func deeplinkQueuedBeforeLoadAndFlushedAfter() async { 700 + let worktree = makeWorktree() 701 + let repository = makeRepository(worktree: worktree) 702 + var repositories = RepositoriesFeature.State() 703 + repositories.repositories = [repository] 704 + repositories.selection = .worktree(worktree.id) 705 + repositories.isInitialLoadComplete = false 706 + let store = TestStore( 707 + initialState: AppFeature.State( 708 + repositories: repositories, 709 + settings: SettingsFeature.State() 710 + ) 711 + ) { 712 + AppFeature() 713 + } withDependencies: { 714 + let worktreeID = worktree.id 715 + $0[DeeplinkClient.self].parse = { _ in .worktree(id: worktreeID, action: .select) } 716 + } 717 + store.exhaustivity = .off 718 + 719 + await store.send(.deeplinkReceived(URL(string: "supacode://worktree/x")!)) { 720 + $0.pendingDeeplinks = [.worktree(id: worktree.id, action: .select)] 721 + } 722 + 723 + let repos = IdentifiedArray(uniqueElements: [repository]) 724 + await store.send(.repositories(.delegate(.repositoriesChanged(repos)))) { 725 + $0.pendingDeeplinks = [] 726 + } 727 + await store.receive(\.deeplink) 728 + await store.receive(\.repositories.selectWorktree) 729 + } 730 + 731 + @Test(.dependencies) func multipleDeeplinksQueuedBeforeLoadAllFlushed() async { 732 + let worktree = makeWorktree() 733 + let repository = makeRepository(worktree: worktree) 734 + var repositories = RepositoriesFeature.State() 735 + repositories.repositories = [repository] 736 + repositories.selection = .worktree(worktree.id) 737 + repositories.isInitialLoadComplete = false 738 + let callCount = LockIsolated(0) 739 + let store = TestStore( 740 + initialState: AppFeature.State( 741 + repositories: repositories, 742 + settings: SettingsFeature.State() 743 + ) 744 + ) { 745 + AppFeature() 746 + } withDependencies: { 747 + let worktreeID = worktree.id 748 + $0[DeeplinkClient.self].parse = { _ in 749 + let current = callCount.withValue { value -> Int in 750 + value += 1 751 + return value 752 + } 753 + return current == 1 754 + ? .worktree(id: worktreeID, action: .pin) 755 + : .worktree(id: worktreeID, action: .select) 756 + } 757 + } 758 + store.exhaustivity = .off 759 + 760 + // First deeplink queued. 761 + await store.send(.deeplinkReceived(URL(string: "supacode://first")!)) { 762 + $0.pendingDeeplinks = [.worktree(id: worktree.id, action: .pin)] 763 + } 764 + // Second deeplink appended. 765 + await store.send(.deeplinkReceived(URL(string: "supacode://second")!)) { 766 + $0.pendingDeeplinks = [ 767 + .worktree(id: worktree.id, action: .pin), 768 + .worktree(id: worktree.id, action: .select), 769 + ] 770 + } 771 + 772 + let repos = IdentifiedArray(uniqueElements: [repository]) 773 + await store.send(.repositories(.delegate(.repositoriesChanged(repos)))) { 774 + $0.pendingDeeplinks = [] 775 + } 776 + // Both deeplinks should be dispatched (pin from first, select from second). 777 + await store.receive(\.deeplink) 778 + await store.receive(\.deeplink) 779 + await store.receive(\.repositories.pinWorktree) 780 + await store.receive(\.repositories.selectWorktree) 781 + } 782 + 783 + // MARK: - URL parsing integration. 784 + 785 + @Test(.dependencies) func deeplinkReceivedParsesAndDispatches() async { 786 + let worktree = makeWorktree() 787 + let store = TestStore( 788 + initialState: AppFeature.State( 789 + repositories: makeRepositoriesState(worktree: worktree), 790 + settings: SettingsFeature.State() 791 + ) 792 + ) { 793 + AppFeature() 794 + } withDependencies: { 795 + $0[DeeplinkClient.self] = .liveValue 796 + } 797 + store.exhaustivity = .off 798 + 799 + let encoded = worktree.id.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! 800 + let url = URL(string: "supacode://worktree/\(encoded)")! 801 + await store.send(.deeplinkReceived(url)) 802 + await store.receive(\.deeplink) 803 + await store.receive(\.repositories.selectWorktree) 804 + } 805 + 806 + @Test(.dependencies) func deeplinkReceivedWithUnknownURLShowsAlert() async { 807 + let worktree = makeWorktree() 808 + let store = TestStore( 809 + initialState: AppFeature.State( 810 + repositories: makeRepositoriesState(worktree: worktree), 811 + settings: SettingsFeature.State() 812 + ) 813 + ) { 814 + AppFeature() 815 + } withDependencies: { 816 + $0[DeeplinkClient.self] = .liveValue 817 + } 818 + store.exhaustivity = .off 819 + 820 + await store.send(.deeplinkReceived(URL(string: "https://example.com")!)) 821 + // Non-supacode scheme is silently ignored (debug log only, no alert). 822 + #expect(store.state.alert == nil) 823 + } 824 + 825 + @Test(.dependencies) func deeplinkReceivedWithUnrecognizedHostShowsAlert() async { 826 + let worktree = makeWorktree() 827 + let store = TestStore( 828 + initialState: AppFeature.State( 829 + repositories: makeRepositoriesState(worktree: worktree), 830 + settings: SettingsFeature.State() 831 + ) 832 + ) { 833 + AppFeature() 834 + } withDependencies: { 835 + $0[DeeplinkClient.self] = .liveValue 836 + } 837 + store.exhaustivity = .off 838 + 839 + await store.send(.deeplinkReceived(URL(string: "supacode://unknown-host")!)) 840 + #expect(store.state.alert != nil) 841 + } 842 + 843 + // MARK: - repositoriesLoaded flush. 844 + 845 + @Test(.dependencies) func deeplinkQueuedBeforeLoadAndFlushedOnRepositoriesLoaded() async { 846 + let worktree = makeWorktree() 847 + let repository = makeRepository(worktree: worktree) 848 + var repositories = RepositoriesFeature.State() 849 + repositories.repositories = [repository] 850 + repositories.selection = .worktree(worktree.id) 851 + repositories.isInitialLoadComplete = false 852 + let store = TestStore( 853 + initialState: AppFeature.State( 854 + repositories: repositories, 855 + settings: SettingsFeature.State() 856 + ) 857 + ) { 858 + AppFeature() 859 + } withDependencies: { 860 + let worktreeID = worktree.id 861 + $0[DeeplinkClient.self].parse = { _ in .worktree(id: worktreeID, action: .select) } 862 + } 863 + store.exhaustivity = .off 864 + 865 + await store.send(.deeplinkReceived(URL(string: "supacode://worktree/x")!)) { 866 + $0.pendingDeeplinks = [.worktree(id: worktree.id, action: .select)] 867 + } 868 + 869 + // Flush via repositoriesLoaded instead of repositoriesChanged delegate. 870 + await store.send(.repositories(.repositoriesLoaded([repository], failures: [], roots: [], animated: false))) { 871 + $0.pendingDeeplinks = [] 872 + } 873 + await store.receive(\.deeplink) 874 + await store.receive(\.repositories.selectWorktree) 875 + } 876 + 877 + // MARK: - openRepositoriesFinished flush. 878 + 879 + @Test(.dependencies) func deeplinkQueuedBeforeLoadAndFlushedOnOpenRepositoriesFinished() async { 880 + let worktree = makeWorktree() 881 + let repository = makeRepository(worktree: worktree) 882 + var repositories = RepositoriesFeature.State() 883 + repositories.repositories = [repository] 884 + repositories.selection = .worktree(worktree.id) 885 + repositories.isInitialLoadComplete = false 886 + let store = TestStore( 887 + initialState: AppFeature.State( 888 + repositories: repositories, 889 + settings: SettingsFeature.State() 890 + ) 891 + ) { 892 + AppFeature() 893 + } withDependencies: { 894 + let worktreeID = worktree.id 895 + $0[DeeplinkClient.self].parse = { _ in .worktree(id: worktreeID, action: .select) } 896 + } 897 + store.exhaustivity = .off 898 + 899 + await store.send(.deeplinkReceived(URL(string: "supacode://worktree/x")!)) { 900 + $0.pendingDeeplinks = [.worktree(id: worktree.id, action: .select)] 901 + } 902 + 903 + // Flush via openRepositoriesFinished instead of repositoriesLoaded or repositoriesChanged. 904 + await store.send( 905 + .repositories(.openRepositoriesFinished([repository], failures: [], invalidRoots: [], roots: [])) 906 + ) { 907 + $0.pendingDeeplinks = [] 908 + } 909 + await store.receive(\.deeplink) 910 + await store.receive(\.repositories.selectWorktree) 911 + } 912 + 913 + // MARK: - repoWorktreeNew with branch through store. 914 + 915 + @Test(.dependencies) func repoWorktreeNewWithBranchDeeplink() async { 916 + let worktree = makeWorktree() 917 + let store = TestStore( 918 + initialState: AppFeature.State( 919 + repositories: makeRepositoriesState(worktree: worktree), 920 + settings: SettingsFeature.State() 921 + ) 922 + ) { 923 + AppFeature() 924 + } withDependencies: { 925 + $0.uuid = .incrementing 926 + } 927 + store.exhaustivity = .off 928 + 929 + await store.send( 930 + .deeplink( 931 + .repoWorktreeNew( 932 + repositoryID: "/tmp/repo", 933 + branch: "feature-x", 934 + baseRef: "main", 935 + fetchOrigin: true 936 + ) 937 + ) 938 + ) 939 + await store.receive(\.repositories.createWorktreeInRepository) 940 + await store.finish() 941 + } 942 + 943 + @Test(.dependencies) func repoWorktreeNewWithUnknownRepoShowsAlert() async { 944 + let worktree = makeWorktree() 945 + let store = makeStore(worktree: worktree) 946 + 947 + await store.send( 948 + .deeplink(.repoWorktreeNew(repositoryID: "/nonexistent", branch: nil, baseRef: nil, fetchOrigin: false))) 949 + #expect(store.state.alert != nil) 950 + } 951 + 952 + // MARK: - Surface focus without input. 953 + 954 + @Test(.dependencies) func surfaceFocusWithoutInputSendsTerminalCommand() async { 955 + let worktree = makeWorktree() 956 + let tabUUID = UUID() 957 + let surfaceUUID = UUID() 958 + let sent = LockIsolated<[TerminalClient.Command]>([]) 959 + let store = TestStore( 960 + initialState: AppFeature.State( 961 + repositories: makeRepositoriesState(worktree: worktree), 962 + settings: SettingsFeature.State() 963 + ) 964 + ) { 965 + AppFeature() 966 + } withDependencies: { 967 + $0.terminalClient.send = { command in 968 + sent.withValue { $0.append(command) } 969 + } 970 + $0.terminalClient.tabExists = { _, _ in true } 971 + $0.terminalClient.surfaceExists = { _, _, _ in true } 972 + } 973 + store.exhaustivity = .off 974 + 975 + await store.send( 976 + .deeplink( 977 + .worktree(id: worktree.id, action: .surface(tabID: tabUUID, surfaceID: surfaceUUID, input: nil)))) 978 + #expect(store.state.deeplinkInputConfirmation == nil) 979 + let hasFocus = sent.value.contains(where: { 980 + if case .focusSurface = $0 { return true } 981 + return false 982 + }) 983 + #expect(hasFocus) 984 + } 985 + 986 + // MARK: - Tab/surface not found alerts. 987 + 988 + @Test(.dependencies) func tabNotFoundShowsAlert() async { 989 + let worktree = makeWorktree() 990 + let tabUUID = UUID() 991 + let store = TestStore( 992 + initialState: AppFeature.State( 993 + repositories: makeRepositoriesState(worktree: worktree), 994 + settings: SettingsFeature.State() 995 + ) 996 + ) { 997 + AppFeature() 998 + } withDependencies: { 999 + $0.terminalClient.tabExists = { _, _ in false } 1000 + } 1001 + store.exhaustivity = .off 1002 + 1003 + await store.send(.deeplink(.worktree(id: worktree.id, action: .tab(tabID: tabUUID)))) 1004 + #expect(store.state.alert != nil) 1005 + } 1006 + 1007 + @Test(.dependencies) func surfaceNotFoundShowsAlert() async { 1008 + let worktree = makeWorktree() 1009 + let tabUUID = UUID() 1010 + let surfaceUUID = UUID() 1011 + let store = TestStore( 1012 + initialState: AppFeature.State( 1013 + repositories: makeRepositoriesState(worktree: worktree), 1014 + settings: SettingsFeature.State() 1015 + ) 1016 + ) { 1017 + AppFeature() 1018 + } withDependencies: { 1019 + $0.terminalClient.tabExists = { _, _ in true } 1020 + $0.terminalClient.surfaceExists = { _, _, _ in false } 1021 + } 1022 + store.exhaustivity = .off 1023 + 1024 + await store.send( 1025 + .deeplink( 1026 + .worktree(id: worktree.id, action: .surface(tabID: tabUUID, surfaceID: surfaceUUID, input: nil)))) 1027 + #expect(store.state.alert != nil) 1028 + } 1029 + 1030 + @Test(.dependencies) func surfaceWithInputValidatesBeforeConfirmation() async { 1031 + let worktree = makeWorktree() 1032 + let tabUUID = UUID() 1033 + let surfaceUUID = UUID() 1034 + let store = TestStore( 1035 + initialState: AppFeature.State( 1036 + repositories: makeRepositoriesState(worktree: worktree), 1037 + settings: SettingsFeature.State() 1038 + ) 1039 + ) { 1040 + AppFeature() 1041 + } withDependencies: { 1042 + $0.terminalClient.tabExists = { _, _ in true } 1043 + $0.terminalClient.surfaceExists = { _, _, _ in false } 1044 + } 1045 + store.exhaustivity = .off 1046 + 1047 + // Surface doesn't exist — should show "not found" alert, not input confirmation. 1048 + await store.send( 1049 + .deeplink( 1050 + .worktree(id: worktree.id, action: .surface(tabID: tabUUID, surfaceID: surfaceUUID, input: "echo test")))) 1051 + #expect(store.state.alert != nil) 1052 + #expect(store.state.deeplinkInputConfirmation == nil) 1053 + } 1054 + 1055 + // MARK: - Helpers. 1056 + 1057 + private func makeWorktree( 1058 + id: String = "/tmp/repo/wt-1", 1059 + name: String = "wt-1" 1060 + ) -> Worktree { 1061 + Worktree( 1062 + id: id, 1063 + name: name, 1064 + detail: "detail", 1065 + workingDirectory: URL(fileURLWithPath: id), 1066 + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo"), 1067 + ) 1068 + } 1069 + 1070 + private func makeRepository(worktree: Worktree) -> Repository { 1071 + Repository( 1072 + id: "/tmp/repo", 1073 + rootURL: URL(fileURLWithPath: "/tmp/repo"), 1074 + name: "repo", 1075 + worktrees: [worktree], 1076 + ) 1077 + } 1078 + 1079 + private func makeRepositoriesState(worktree: Worktree) -> RepositoriesFeature.State { 1080 + let repository = makeRepository(worktree: worktree) 1081 + var repositoriesState = RepositoriesFeature.State() 1082 + repositoriesState.repositories = [repository] 1083 + repositoriesState.selection = .worktree(worktree.id) 1084 + repositoriesState.isInitialLoadComplete = true 1085 + return repositoriesState 1086 + } 1087 + 1088 + private func makeStore(worktree: Worktree) -> TestStoreOf<AppFeature> { 1089 + let store = TestStore( 1090 + initialState: AppFeature.State( 1091 + repositories: makeRepositoriesState(worktree: worktree), 1092 + settings: SettingsFeature.State() 1093 + ) 1094 + ) { 1095 + AppFeature() 1096 + } withDependencies: { 1097 + $0.terminalClient.tabExists = { _, _ in true } 1098 + $0.terminalClient.surfaceExists = { _, _, _ in true } 1099 + } 1100 + store.exhaustivity = .off 1101 + return store 1102 + } 1103 + 1104 + private func makeConfirmationState( 1105 + worktree: Worktree, 1106 + input: String 1107 + ) -> DeeplinkInputConfirmationFeature.State { 1108 + makeConfirmationState( 1109 + worktreeID: worktree.id, 1110 + worktreeName: worktree.name, 1111 + repositoryName: "repo", 1112 + input: input, 1113 + ) 1114 + } 1115 + 1116 + private func makeConfirmationState( 1117 + worktreeID: Worktree.ID, 1118 + worktreeName: String, 1119 + repositoryName: String?, 1120 + input: String 1121 + ) -> DeeplinkInputConfirmationFeature.State { 1122 + DeeplinkInputConfirmationFeature.State( 1123 + worktreeID: worktreeID, 1124 + worktreeName: worktreeName, 1125 + repositoryName: repositoryName, 1126 + message: .command(input), 1127 + action: .tabNew(input: input, id: nil), 1128 + ) 1129 + } 1130 + }
+2 -2
supacodeTests/AppFeatureTerminalSetupScriptTests.swift
··· 35 35 $0.repositories.pendingSetupScriptWorktreeIDs.remove(worktree.id) 36 36 } 37 37 await store.finish() 38 - #expect(sent.value == [.createTab(worktree, runSetupScriptIfNew: true)]) 38 + #expect(sent.value == [.createTab(worktree, runSetupScriptIfNew: true, id: nil)]) 39 39 } 40 40 41 41 @Test(.dependencies) func newTerminalWithoutSetupScriptDoesNotConsume() async { ··· 61 61 62 62 await store.send(.newTerminal) 63 63 await store.finish() 64 - #expect(sent.value == [.createTab(worktree, runSetupScriptIfNew: false)]) 64 + #expect(sent.value == [.createTab(worktree, runSetupScriptIfNew: false, id: nil)]) 65 65 } 66 66 67 67 @Test(.dependencies) func tabCreatedDoesNotConsumeSetupScript() async {
+375
supacodeTests/DeeplinkClientTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import supacode 5 + 6 + @MainActor 7 + struct DeeplinkClientTests { 8 + private let parse = DeeplinkClient.liveValue.parse 9 + 10 + // MARK: - Open. 11 + 12 + @Test func emptyURLReturnsOpen() { 13 + let url = URL(string: "supacode://")! 14 + #expect(parse(url) == .open) 15 + } 16 + 17 + @Test func helpURLReturnsHelp() { 18 + let url = URL(string: "supacode://help")! 19 + #expect(parse(url) == .help) 20 + } 21 + 22 + @Test func wrongSchemeReturnsNil() { 23 + let url = URL(string: "https://worktree/abc/select")! 24 + #expect(parse(url) == nil) 25 + } 26 + 27 + // MARK: - Worktree actions. 28 + 29 + @Test func worktreeRun() { 30 + let encoded = "%2Ftmp%2Frepo%2Fwt-1" 31 + let url = URL(string: "supacode://worktree/\(encoded)/run")! 32 + #expect(parse(url) == .worktree(id: "/tmp/repo/wt-1", action: .run)) 33 + } 34 + 35 + @Test func worktreeArchive() { 36 + let encoded = "%2Ftmp%2Frepo%2Fwt-1" 37 + let url = URL(string: "supacode://worktree/\(encoded)/archive")! 38 + #expect(parse(url) == .worktree(id: "/tmp/repo/wt-1", action: .archive)) 39 + } 40 + 41 + @Test func worktreeUnarchive() { 42 + let encoded = "%2Ftmp%2Frepo%2Fwt-1" 43 + let url = URL(string: "supacode://worktree/\(encoded)/unarchive")! 44 + #expect(parse(url) == .worktree(id: "/tmp/repo/wt-1", action: .unarchive)) 45 + } 46 + 47 + @Test func worktreeDelete() { 48 + let encoded = "%2Ftmp%2Frepo%2Fwt-1" 49 + let url = URL(string: "supacode://worktree/\(encoded)/delete")! 50 + #expect(parse(url) == .worktree(id: "/tmp/repo/wt-1", action: .delete)) 51 + } 52 + 53 + @Test func worktreePin() { 54 + let encoded = "%2Ftmp%2Frepo%2Fwt-1" 55 + let url = URL(string: "supacode://worktree/\(encoded)/pin")! 56 + #expect(parse(url) == .worktree(id: "/tmp/repo/wt-1", action: .pin)) 57 + } 58 + 59 + @Test func worktreeUnpin() { 60 + let encoded = "%2Ftmp%2Frepo%2Fwt-1" 61 + let url = URL(string: "supacode://worktree/\(encoded)/unpin")! 62 + #expect(parse(url) == .worktree(id: "/tmp/repo/wt-1", action: .unpin)) 63 + } 64 + 65 + @Test func worktreeMissingActionDefaultsToSelect() { 66 + let encoded = "%2Ftmp%2Frepo%2Fwt-1" 67 + let url = URL(string: "supacode://worktree/\(encoded)")! 68 + #expect(parse(url) == .worktree(id: "/tmp/repo/wt-1", action: .select)) 69 + } 70 + 71 + @Test func worktreeUnknownActionReturnsNil() { 72 + let encoded = "%2Ftmp%2Frepo%2Fwt-1" 73 + let url = URL(string: "supacode://worktree/\(encoded)/explode")! 74 + #expect(parse(url) == nil) 75 + } 76 + 77 + // MARK: - Tab actions. 78 + 79 + @Test func worktreeTabWithValidUUID() { 80 + let encoded = "%2Ftmp%2Frepo%2Fwt-1" 81 + let tabUUID = UUID(uuidString: "550E8400-E29B-41D4-A716-446655440000")! 82 + let url = URL(string: "supacode://worktree/\(encoded)/tab/550E8400-E29B-41D4-A716-446655440000")! 83 + #expect(parse(url) == .worktree(id: "/tmp/repo/wt-1", action: .tab(tabID: tabUUID))) 84 + } 85 + 86 + @Test func worktreeTabWithInvalidUUIDReturnsNil() { 87 + let encoded = "%2Ftmp%2Frepo%2Fwt-1" 88 + let url = URL(string: "supacode://worktree/\(encoded)/tab/not-a-uuid")! 89 + #expect(parse(url) == nil) 90 + } 91 + 92 + @Test func worktreeTabWithoutTabIDReturnsNil() { 93 + let encoded = "%2Ftmp%2Frepo%2Fwt-1" 94 + let url = URL(string: "supacode://worktree/\(encoded)/tab")! 95 + #expect(parse(url) == nil) 96 + } 97 + 98 + @Test func worktreeTabNewWithoutInput() { 99 + let encoded = "%2Ftmp%2Frepo%2Fwt-1" 100 + let url = URL(string: "supacode://worktree/\(encoded)/tab/new")! 101 + #expect(parse(url) == .worktree(id: "/tmp/repo/wt-1", action: .tabNew(input: nil, id: nil))) 102 + } 103 + 104 + @Test func worktreeTabNewWithInput() { 105 + let encoded = "%2Ftmp%2Frepo%2Fwt-1" 106 + let url = URL(string: "supacode://worktree/\(encoded)/tab/new?input=echo%20hello")! 107 + #expect(parse(url) == .worktree(id: "/tmp/repo/wt-1", action: .tabNew(input: "echo hello", id: nil))) 108 + } 109 + 110 + @Test func worktreeTabDestroy() { 111 + let encoded = "%2Ftmp%2Frepo%2Fwt-1" 112 + let tabUUID = UUID(uuidString: "550E8400-E29B-41D4-A716-446655440000")! 113 + let url = URL(string: "supacode://worktree/\(encoded)/tab/\(tabUUID.uuidString)/destroy")! 114 + #expect(parse(url) == .worktree(id: "/tmp/repo/wt-1", action: .tabDestroy(tabID: tabUUID))) 115 + } 116 + 117 + // MARK: - Surface actions. 118 + 119 + @Test func worktreeSurfaceFocus() { 120 + let encoded = "%2Ftmp%2Frepo%2Fwt-1" 121 + let tabUUID = UUID(uuidString: "550E8400-E29B-41D4-A716-446655440000")! 122 + let surfaceUUID = UUID(uuidString: "660E8400-E29B-41D4-A716-446655440000")! 123 + let url = URL( 124 + string: "supacode://worktree/\(encoded)/tab/\(tabUUID.uuidString)/surface/\(surfaceUUID.uuidString)" 125 + )! 126 + #expect( 127 + parse(url) 128 + == .worktree(id: "/tmp/repo/wt-1", action: .surface(tabID: tabUUID, surfaceID: surfaceUUID, input: nil)) 129 + ) 130 + } 131 + 132 + @Test func worktreeSurfaceFocusWithInput() { 133 + let encoded = "%2Ftmp%2Frepo%2Fwt-1" 134 + let tabUUID = UUID(uuidString: "550E8400-E29B-41D4-A716-446655440000")! 135 + let surfaceUUID = UUID(uuidString: "660E8400-E29B-41D4-A716-446655440000")! 136 + let url = URL( 137 + string: "supacode://worktree/\(encoded)/tab/\(tabUUID.uuidString)/surface/\(surfaceUUID.uuidString)?input=ls" 138 + )! 139 + #expect( 140 + parse(url) 141 + == .worktree(id: "/tmp/repo/wt-1", action: .surface(tabID: tabUUID, surfaceID: surfaceUUID, input: "ls")) 142 + ) 143 + } 144 + 145 + @Test func worktreeSurfaceSplitHorizontal() { 146 + let encoded = "%2Ftmp%2Frepo%2Fwt-1" 147 + let tabUUID = UUID(uuidString: "550E8400-E29B-41D4-A716-446655440000")! 148 + let surfaceUUID = UUID(uuidString: "660E8400-E29B-41D4-A716-446655440000")! 149 + let base = "supacode://worktree/\(encoded)/tab/\(tabUUID.uuidString)" 150 + let url = URL(string: "\(base)/surface/\(surfaceUUID.uuidString)/split?direction=horizontal")! 151 + #expect( 152 + parse(url) 153 + == .worktree( 154 + id: "/tmp/repo/wt-1", 155 + action: .surfaceSplit( 156 + tabID: tabUUID, surfaceID: surfaceUUID, direction: .horizontal, input: nil, id: nil 157 + ), 158 + ) 159 + ) 160 + } 161 + 162 + @Test func worktreeSurfaceSplitVerticalWithInput() { 163 + let encoded = "%2Ftmp%2Frepo%2Fwt-1" 164 + let tabUUID = UUID(uuidString: "550E8400-E29B-41D4-A716-446655440000")! 165 + let surfaceUUID = UUID(uuidString: "660E8400-E29B-41D4-A716-446655440000")! 166 + let base = "supacode://worktree/\(encoded)/tab/\(tabUUID.uuidString)" 167 + let url = URL(string: "\(base)/surface/\(surfaceUUID.uuidString)/split?direction=vertical&input=echo%20hi")! 168 + #expect( 169 + parse(url) 170 + == .worktree( 171 + id: "/tmp/repo/wt-1", 172 + action: .surfaceSplit( 173 + tabID: tabUUID, surfaceID: surfaceUUID, direction: .vertical, input: "echo hi", id: nil), 174 + ) 175 + ) 176 + } 177 + 178 + @Test func worktreeSurfaceSplitDefaultsToHorizontal() { 179 + let encoded = "%2Ftmp%2Frepo%2Fwt-1" 180 + let tabUUID = UUID(uuidString: "550E8400-E29B-41D4-A716-446655440000")! 181 + let surfaceUUID = UUID(uuidString: "660E8400-E29B-41D4-A716-446655440000")! 182 + let url = URL( 183 + string: 184 + "supacode://worktree/\(encoded)/tab/\(tabUUID.uuidString)/surface/\(surfaceUUID.uuidString)/split" 185 + )! 186 + #expect( 187 + parse(url) 188 + == .worktree( 189 + id: "/tmp/repo/wt-1", 190 + action: .surfaceSplit(tabID: tabUUID, surfaceID: surfaceUUID, direction: .horizontal, input: nil, id: nil), 191 + ) 192 + ) 193 + } 194 + 195 + @Test func worktreeSurfaceInvalidUUIDReturnsNil() { 196 + let encoded = "%2Ftmp%2Frepo%2Fwt-1" 197 + let tabUUID = UUID(uuidString: "550E8400-E29B-41D4-A716-446655440000")! 198 + let url = URL( 199 + string: "supacode://worktree/\(encoded)/tab/\(tabUUID.uuidString)/surface/not-a-uuid" 200 + )! 201 + #expect(parse(url) == nil) 202 + } 203 + 204 + // MARK: - Repo actions. 205 + 206 + @Test func repoOpen() { 207 + let url = URL(string: "supacode://repo/open?path=%2Ftmp%2Fmy-repo")! 208 + #expect(parse(url) == .repoOpen(path: URL(fileURLWithPath: "/tmp/my-repo"))) 209 + } 210 + 211 + @Test func repoOpenMissingPathReturnsNil() { 212 + let url = URL(string: "supacode://repo/open")! 213 + #expect(parse(url) == nil) 214 + } 215 + 216 + @Test func repoWorktreeNewWithBranch() { 217 + let repoEncoded = "%2Ftmp%2Frepo" 218 + let url = URL( 219 + string: "supacode://repo/\(repoEncoded)/worktree/new?branch=feature-x&base=main&fetch=true" 220 + )! 221 + #expect( 222 + parse(url) 223 + == .repoWorktreeNew( 224 + repositoryID: "/tmp/repo", 225 + branch: "feature-x", 226 + baseRef: "main", 227 + fetchOrigin: true 228 + ) 229 + ) 230 + } 231 + 232 + @Test func repoWorktreeNewWithoutBranch() { 233 + let repoEncoded = "%2Ftmp%2Frepo" 234 + let url = URL(string: "supacode://repo/\(repoEncoded)/worktree/new")! 235 + #expect( 236 + parse(url) 237 + == .repoWorktreeNew( 238 + repositoryID: "/tmp/repo", 239 + branch: nil, 240 + baseRef: nil, 241 + fetchOrigin: false 242 + ) 243 + ) 244 + } 245 + 246 + @Test func repoUnknownPathReturnsNil() { 247 + let repoEncoded = "%2Ftmp%2Frepo" 248 + let url = URL(string: "supacode://repo/\(repoEncoded)/unknown")! 249 + #expect(parse(url) == nil) 250 + } 251 + 252 + // MARK: - Settings. 253 + 254 + @Test func settingsWithoutSection() { 255 + let url = URL(string: "supacode://settings")! 256 + #expect(parse(url) == .settings(section: nil)) 257 + } 258 + 259 + @Test func settingsWithUnknownSectionReturnsNilSection() { 260 + let url = URL(string: "supacode://settings/nonexistent")! 261 + #expect(parse(url) == .settings(section: nil)) 262 + } 263 + 264 + @Test func settingsWithSection() { 265 + let url = URL(string: "supacode://settings/worktrees")! 266 + #expect(parse(url) == .settings(section: .worktrees)) 267 + } 268 + 269 + @Test func settingsRepoWithValidID() { 270 + let url = URL(string: "supacode://settings/repo/%2Ftmp%2Frepo")! 271 + #expect(parse(url) == .settingsRepo(repositoryID: "/tmp/repo")) 272 + } 273 + 274 + @Test func settingsRepoWithMissingIDReturnsNil() { 275 + let url = URL(string: "supacode://settings/repo")! 276 + #expect(parse(url) == nil) 277 + } 278 + 279 + // MARK: - Surface destroy. 280 + 281 + @Test func worktreeSurfaceDestroy() { 282 + let encoded = "%2Ftmp%2Frepo%2Fwt-1" 283 + let tabUUID = UUID(uuidString: "550E8400-E29B-41D4-A716-446655440000")! 284 + let surfaceUUID = UUID(uuidString: "660E8400-E29B-41D4-A716-446655440000")! 285 + let base = "supacode://worktree/\(encoded)/tab/\(tabUUID.uuidString)" 286 + let url = URL(string: "\(base)/surface/\(surfaceUUID.uuidString)/destroy")! 287 + #expect( 288 + parse(url) 289 + == .worktree( 290 + id: "/tmp/repo/wt-1", 291 + action: .surfaceDestroy(tabID: tabUUID, surfaceID: surfaceUUID), 292 + ) 293 + ) 294 + } 295 + 296 + // MARK: - Tab new with ID query parameter. 297 + 298 + @Test func worktreeTabNewWithID() { 299 + let encoded = "%2Ftmp%2Frepo%2Fwt-1" 300 + let tabID = UUID(uuidString: "770E8400-E29B-41D4-A716-446655440000")! 301 + let url = URL(string: "supacode://worktree/\(encoded)/tab/new?id=\(tabID.uuidString)")! 302 + #expect(parse(url) == .worktree(id: "/tmp/repo/wt-1", action: .tabNew(input: nil, id: tabID))) 303 + } 304 + 305 + // MARK: - Surface split with ID query parameter. 306 + 307 + @Test func worktreeSurfaceSplitWithID() { 308 + let encoded = "%2Ftmp%2Frepo%2Fwt-1" 309 + let tabUUID = UUID(uuidString: "550E8400-E29B-41D4-A716-446655440000")! 310 + let surfaceUUID = UUID(uuidString: "660E8400-E29B-41D4-A716-446655440000")! 311 + let newID = UUID(uuidString: "880E8400-E29B-41D4-A716-446655440000")! 312 + let base = "supacode://worktree/\(encoded)/tab/\(tabUUID.uuidString)" 313 + let url = URL(string: "\(base)/surface/\(surfaceUUID.uuidString)/split?id=\(newID.uuidString)")! 314 + #expect( 315 + parse(url) 316 + == .worktree( 317 + id: "/tmp/repo/wt-1", 318 + action: .surfaceSplit(tabID: tabUUID, surfaceID: surfaceUUID, direction: .horizontal, input: nil, id: newID), 319 + ) 320 + ) 321 + } 322 + 323 + // MARK: - Invalid split direction. 324 + 325 + @Test func worktreeSurfaceSplitInvalidDirectionReturnsNil() { 326 + let encoded = "%2Ftmp%2Frepo%2Fwt-1" 327 + let tabUUID = UUID(uuidString: "550E8400-E29B-41D4-A716-446655440000")! 328 + let surfaceUUID = UUID(uuidString: "660E8400-E29B-41D4-A716-446655440000")! 329 + let base = "supacode://worktree/\(encoded)/tab/\(tabUUID.uuidString)" 330 + let url = URL(string: "\(base)/surface/\(surfaceUUID.uuidString)/split?direction=diagonal")! 331 + #expect(parse(url) == nil) 332 + } 333 + 334 + // MARK: - Repo open edge cases. 335 + 336 + @Test func repoOpenWithEmptyPathReturnsNil() { 337 + let url = URL(string: "supacode://repo/open?path=")! 338 + #expect(parse(url) == nil) 339 + } 340 + 341 + @Test func repoOpenWithRelativePathReturnsNil() { 342 + let url = URL(string: "supacode://repo/open?path=relative/path")! 343 + #expect(parse(url) == nil) 344 + } 345 + 346 + // MARK: - Worktree stop. 347 + 348 + @Test func worktreeStop() { 349 + let encoded = "%2Ftmp%2Frepo%2Fwt-1" 350 + let url = URL(string: "supacode://worktree/\(encoded)/stop")! 351 + #expect(parse(url) == .worktree(id: "/tmp/repo/wt-1", action: .stop)) 352 + } 353 + 354 + // MARK: - Worktree with no ID. 355 + 356 + @Test func worktreeWithNoIDReturnsNil() { 357 + let url = URL(string: "supacode://worktree")! 358 + #expect(parse(url) == nil) 359 + } 360 + 361 + // MARK: - Trailing slash normalization. 362 + 363 + @Test func worktreeIDWithTrailingSlashIsNormalized() { 364 + let encoded = "%2Ftmp%2Frepo%2Fwt-1%2F" 365 + let url = URL(string: "supacode://worktree/\(encoded)")! 366 + #expect(parse(url) == .worktree(id: "/tmp/repo/wt-1", action: .select)) 367 + } 368 + 369 + // MARK: - Unknown host. 370 + 371 + @Test func unknownHostReturnsNil() { 372 + let url = URL(string: "supacode://unknown/something")! 373 + #expect(parse(url) == nil) 374 + } 375 + }
+77
supacodeTests/DeeplinkInputConfirmationFeatureTests.swift
··· 1 + import ComposableArchitecture 2 + import Foundation 3 + import Testing 4 + 5 + @testable import supacode 6 + 7 + @MainActor 8 + @Suite(.serialized) 9 + struct DeeplinkInputConfirmationFeatureTests { 10 + @Test func runTappedDelegatesConfirmWithAlwaysAllowFalse() async { 11 + let state = DeeplinkInputConfirmationFeature.State( 12 + worktreeID: "/tmp/wt", 13 + worktreeName: "wt", 14 + repositoryName: "repo", 15 + message: .command("echo hello"), 16 + action: .tabNew(input: "echo hello", id: nil), 17 + ) 18 + let store = TestStore(initialState: state) { 19 + DeeplinkInputConfirmationFeature() 20 + } 21 + 22 + await store.send(.runTapped) 23 + await store.receive( 24 + .delegate(.confirm(worktreeID: "/tmp/wt", action: .tabNew(input: "echo hello", id: nil), alwaysAllow: false))) 25 + } 26 + 27 + @Test func runTappedDelegatesConfirmWithAlwaysAllowTrue() async { 28 + var state = DeeplinkInputConfirmationFeature.State( 29 + worktreeID: "/tmp/wt", 30 + worktreeName: "wt", 31 + repositoryName: "repo", 32 + message: .command("echo hello"), 33 + action: .tabNew(input: "echo hello", id: nil), 34 + ) 35 + state.alwaysAllow = true 36 + let store = TestStore(initialState: state) { 37 + DeeplinkInputConfirmationFeature() 38 + } 39 + 40 + await store.send(.runTapped) 41 + await store.receive( 42 + .delegate(.confirm(worktreeID: "/tmp/wt", action: .tabNew(input: "echo hello", id: nil), alwaysAllow: true))) 43 + } 44 + 45 + @Test func cancelTappedDelegatesCancel() async { 46 + let state = DeeplinkInputConfirmationFeature.State( 47 + worktreeID: "/tmp/wt", 48 + worktreeName: "wt", 49 + repositoryName: nil, 50 + message: .command("rm -rf /"), 51 + action: .tabNew(input: "rm -rf /", id: nil), 52 + ) 53 + let store = TestStore(initialState: state) { 54 + DeeplinkInputConfirmationFeature() 55 + } 56 + 57 + await store.send(.cancelTapped) 58 + await store.receive(.delegate(.cancel)) 59 + } 60 + 61 + @Test func alwaysAllowBindingUpdatesState() async { 62 + let state = DeeplinkInputConfirmationFeature.State( 63 + worktreeID: "/tmp/wt", 64 + worktreeName: "wt", 65 + repositoryName: "repo", 66 + message: .command("echo hello"), 67 + action: .tabNew(input: "echo hello", id: nil), 68 + ) 69 + let store = TestStore(initialState: state) { 70 + DeeplinkInputConfirmationFeature() 71 + } 72 + 73 + await store.send(.binding(.set(\.alwaysAllow, true))) { 74 + $0.alwaysAllow = true 75 + } 76 + } 77 + }
+2 -2
supacodeTests/RepositoriesFeatureTests.swift
··· 274 274 id: repoBID, 275 275 worktrees: [makeWorktree(id: "\(repoBID)/wt1", name: "wt1", repoRoot: repoBID)] 276 276 ) 277 - var initialState = makeState(repositories: [repoA, repoB]) 277 + let initialState = makeState(repositories: [repoA, repoB]) 278 278 initialState.$collapsedRepositoryIDs.withLock { $0 = [repoA.id, repoB.id, "/tmp/missing"] } 279 279 let store = TestStore(initialState: initialState) { 280 280 RepositoriesFeature() ··· 4277 4277 $0.repositoryPersistence.loadRoots = { [repoRootA, repoRootB] } 4278 4278 $0.gitClient.worktrees = { root in 4279 4279 let path = root.path(percentEncoded: false) 4280 - startedRoots.withValue { $0.insert(path) } 4280 + _ = startedRoots.withValue { $0.insert(path) } 4281 4281 if path == repoRootA { 4282 4282 await gate.wait() 4283 4283 return [worktreeA]
+2 -2
supacodeTests/SettingsFeatureAgentHookTests.swift
··· 122 122 } withDependencies: { 123 123 $0[ClaudeSettingsClient.self].checkInstalled = { progress in 124 124 let key = progress ? "claudeProgress" : "claudeNotifications" 125 - startedChecks.withValue { $0.insert(key) } 125 + _ = startedChecks.withValue { $0.insert(key) } 126 126 await withCheckedContinuation { continuation in 127 127 continuations.withValue { $0.append(continuation) } 128 128 } ··· 130 130 } 131 131 $0[CodexSettingsClient.self].checkInstalled = { progress in 132 132 let key = progress ? "codexProgress" : "codexNotifications" 133 - startedChecks.withValue { $0.insert(key) } 133 + _ = startedChecks.withValue { $0.insert(key) } 134 134 await withCheckedContinuation { continuation in 135 135 continuations.withValue { $0.append(continuation) } 136 136 }
+1 -1
supacodeTests/SplitTreeTests.swift
··· 38 38 let tree = try SplitTree(view: first) 39 39 .inserting(view: second, at: first, direction: .right) 40 40 41 - let zoomed = tree.settingZoomed(try #require(tree.find(id: second.id))) 41 + let zoomed = tree.settingZoomed(tree.find(id: second.id)!) 42 42 let visibleLeaves = zoomed.visibleLeaves() 43 43 44 44 #expect(visibleLeaves.count == 1)
+13 -10
supacodeTests/TerminalLayoutSnapshotTests.swift
··· 8 8 let snapshot = TerminalLayoutSnapshot( 9 9 tabs: [ 10 10 TerminalLayoutSnapshot.TabSnapshot( 11 + id: nil, 11 12 title: "main 1", 12 13 icon: "terminal", 13 14 tintColor: nil, ··· 15 16 TerminalLayoutSnapshot.SplitSnapshot( 16 17 direction: .horizontal, 17 18 ratio: 0.7, 18 - left: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(workingDirectory: "/Users/test/project")), 19 + left: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: nil, workingDirectory: "/Users/test/project")), 19 20 right: .split( 20 21 TerminalLayoutSnapshot.SplitSnapshot( 21 22 direction: .vertical, 22 23 ratio: 0.4, 23 - left: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(workingDirectory: "/tmp")), 24 - right: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(workingDirectory: nil)) 24 + left: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: nil, workingDirectory: "/tmp")), 25 + right: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: nil, workingDirectory: nil)) 25 26 ) 26 27 ) 27 28 ) ··· 29 30 focusedLeafIndex: 1 30 31 ), 31 32 TerminalLayoutSnapshot.TabSnapshot( 33 + id: nil, 32 34 title: "main 2", 33 35 icon: nil, 34 36 tintColor: nil, 35 - layout: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(workingDirectory: "/Users/test")), 37 + layout: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: nil, workingDirectory: "/Users/test")), 36 38 focusedLeafIndex: 0 37 39 ), 38 40 ], ··· 51 53 TerminalLayoutSnapshot.SplitSnapshot( 52 54 direction: .horizontal, 53 55 ratio: 0.5, 54 - left: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(workingDirectory: "/first")), 55 - right: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(workingDirectory: "/second")) 56 + left: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: nil, workingDirectory: "/first")), 57 + right: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: nil, workingDirectory: "/second")) 56 58 ) 57 59 ) 58 60 #expect(node.firstLeaf.workingDirectory == "/first") ··· 63 65 TerminalLayoutSnapshot.SplitSnapshot( 64 66 direction: .horizontal, 65 67 ratio: 0.5, 66 - left: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(workingDirectory: nil)), 68 + left: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: nil, workingDirectory: nil)), 67 69 right: .split( 68 70 TerminalLayoutSnapshot.SplitSnapshot( 69 71 direction: .vertical, 70 72 ratio: 0.5, 71 - left: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(workingDirectory: nil)), 72 - right: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(workingDirectory: nil)) 73 + left: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: nil, workingDirectory: nil)), 74 + right: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: nil, workingDirectory: nil)) 73 75 ) 74 76 ) 75 77 ) ··· 81 83 let snapshot = TerminalLayoutSnapshot( 82 84 tabs: [ 83 85 TerminalLayoutSnapshot.TabSnapshot( 86 + id: nil, 84 87 title: "tab", 85 88 icon: nil, 86 89 tintColor: nil, 87 - layout: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(workingDirectory: "/home")), 90 + layout: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: nil, workingDirectory: "/home")), 88 91 focusedLeafIndex: 0 89 92 ), 90 93 ],
-90
supacodeTests/WorktreeEnvironmentTests.swift
··· 19 19 #expect(env.count == 2) 20 20 } 21 21 22 - @Test func exportPrefixFormatsCorrectly() { 23 - let worktree = Worktree( 24 - id: "/tmp/repo/wt-1", 25 - name: "feature-branch", 26 - detail: "detail", 27 - workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-1"), 28 - repositoryRootURL: URL(fileURLWithPath: "/tmp/repo/.bare"), 29 - ) 30 - let exports = worktree.scriptEnvironmentExportPrefix 31 - #expect(exports.contains("export SUPACODE_WORKTREE_PATH='/tmp/repo/wt-1'")) 32 - #expect(exports.contains("export SUPACODE_ROOT_PATH='/tmp/repo/.bare'")) 33 - #expect(exports.hasSuffix("\n")) 34 - } 35 - 36 - @Test func exportPrefixIsSortedByKey() { 37 - let worktree = Worktree( 38 - id: "/tmp/repo/wt-1", 39 - name: "feature-branch", 40 - detail: "detail", 41 - workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-1"), 42 - repositoryRootURL: URL(fileURLWithPath: "/tmp/repo/.bare"), 43 - ) 44 - let lines = worktree.scriptEnvironmentExportPrefix 45 - .trimmingCharacters(in: .newlines) 46 - .components(separatedBy: "\n") 47 - #expect(lines.count == 2) 48 - #expect(lines[0].contains("SUPACODE_ROOT_PATH")) 49 - #expect(lines[1].contains("SUPACODE_WORKTREE_PATH")) 50 - } 51 - 52 - @Test func exportPrefixQuotesPathsWithSpaces() { 53 - let worktree = Worktree( 54 - id: "/tmp/my repo/wt 1", 55 - name: "feature-branch", 56 - detail: "detail", 57 - workingDirectory: URL(fileURLWithPath: "/tmp/my repo/wt 1"), 58 - repositoryRootURL: URL(fileURLWithPath: "/tmp/my repo/.bare"), 59 - ) 60 - let exports = worktree.scriptEnvironmentExportPrefix 61 - #expect(exports.contains("export SUPACODE_WORKTREE_PATH='/tmp/my repo/wt 1'")) 62 - #expect(exports.contains("export SUPACODE_ROOT_PATH='/tmp/my repo/.bare'")) 63 - } 64 - 65 22 @Test func blockingScriptLaunchWritesScriptAndMetadataFiles() throws { 66 - let worktree = Worktree( 67 - id: "/tmp/repo/wt-1", 68 - name: "feature-branch", 69 - detail: "detail", 70 - workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-1"), 71 - repositoryRootURL: URL(fileURLWithPath: "/tmp/repo"), 72 - ) 73 - 74 23 let launch = try #require( 75 24 try makeBlockingScriptLaunch( 76 25 script: """ 77 26 docker compose down 78 27 codex exec "test" 79 28 """, 80 - environment: worktree.scriptEnvironment, 81 29 shellPath: "/opt/homebrew/bin/fish" 82 30 ) 83 31 ) ··· 87 35 88 36 let scriptContents = try String(contentsOf: launch.scriptURL, encoding: .utf8) 89 37 let runnerContents = try String(contentsOf: launch.runnerURL, encoding: .utf8) 90 - let rootPathContents = try String(contentsOf: launch.rootPathURL, encoding: .utf8) 91 - let worktreePathContents = try String(contentsOf: launch.worktreePathURL, encoding: .utf8) 92 38 let shellPathContents = try String(contentsOf: launch.shellPathURL, encoding: .utf8) 93 39 94 40 #expect( ··· 97 43 ) 98 44 #expect(launch.commandInput == shellSingleQuoted(launch.runnerURL.path(percentEncoded: false)) + "\n") 99 45 #expect(scriptContents == "docker compose down\ncodex exec \"test\"\n") 100 - #expect(rootPathContents == "/tmp/repo\n") 101 - #expect(worktreePathContents == "/tmp/repo/wt-1\n") 102 46 #expect(shellPathContents == "/opt/homebrew/bin/fish\n") 103 - #expect( 104 - runnerContents.contains( 105 - "IFS= read -r SUPACODE_ROOT_PATH < \(shellSingleQuoted(launch.rootPathURL.path(percentEncoded: false)))" 106 - ) 107 - == true 108 - ) 109 - #expect( 110 - runnerContents.contains( 111 - "IFS= read -r SUPACODE_WORKTREE_PATH < \(shellSingleQuoted(launch.worktreePathURL.path(percentEncoded: false)))" 112 - ) 113 - == true 114 - ) 115 47 #expect( 116 48 runnerContents.contains( 117 49 "IFS= read -r SUPACODE_SHELL_PATH < \(shellSingleQuoted(launch.shellPathURL.path(percentEncoded: false)))" ··· 129 61 #expect(runnerContents.contains("codex exec \"test\"") == false) 130 62 } 131 63 132 - @Test func blockingScriptLaunchReturnsNilWhenRequiredEnvironmentIsMissing() throws { 133 - #expect( 134 - try makeBlockingScriptLaunch( 135 - script: "echo test", 136 - environment: ["SUPACODE_ROOT_PATH": "/tmp/repo"], 137 - shellPath: "/bin/zsh" 138 - ) == nil 139 - ) 140 - } 141 - 142 64 @Test func blockingScriptLaunchReturnsNilForWhitespaceOnlyScripts() throws { 143 65 #expect( 144 66 try makeBlockingScriptLaunch( 145 67 script: """ 146 68 147 69 """, 148 - environment: [ 149 - "SUPACODE_ROOT_PATH": "/tmp/repo", 150 - "SUPACODE_WORKTREE_PATH": "/tmp/repo/wt-1", 151 - ], 152 70 shellPath: "/bin/zsh" 153 71 ) == nil 154 72 ) ··· 158 76 let launch = try #require( 159 77 try makeBlockingScriptLaunch( 160 78 script: "exit 1", 161 - environment: [ 162 - "SUPACODE_ROOT_PATH": "/tmp/repo", 163 - "SUPACODE_WORKTREE_PATH": "/tmp/repo/wt-1", 164 - ], 165 79 shellPath: "/bin/zsh" 166 80 ) 167 81 ) ··· 194 108 let launch = try #require( 195 109 try makeBlockingScriptLaunch( 196 110 script: "exit 1", 197 - environment: [ 198 - "SUPACODE_ROOT_PATH": "/tmp/repo", 199 - "SUPACODE_WORKTREE_PATH": "/tmp/repo/wt-1", 200 - ], 201 111 shellPath: "/bin/zsh", 202 112 baseDirectoryURL: baseDirectoryURL 203 113 )
+5 -3
supacodeTests/WorktreeTerminalManagerTests.swift
··· 691 691 let secondTabId = tabIds[1] 692 692 693 693 // Select the second tab first. 694 - manager.handleCommand(.selectTab(worktree, tabId: secondTabId)) 694 + manager.handleCommand(.selectTab(worktree, tabID: secondTabId)) 695 695 #expect(state.tabManager.selectedTabId == secondTabId) 696 696 697 697 // Select the first tab. 698 - manager.handleCommand(.selectTab(worktree, tabId: firstTabId)) 698 + manager.handleCommand(.selectTab(worktree, tabID: firstTabId)) 699 699 #expect(state.tabManager.selectedTabId == firstTabId) 700 700 } 701 701 ··· 716 716 state.closeTab(tabId) 717 717 let selectedBefore = state.tabManager.selectedTabId 718 718 719 - manager.handleCommand(.selectTab(worktree, tabId: tabId)) 719 + manager.handleCommand(.selectTab(worktree, tabID: tabId)) 720 720 721 721 // Selection should not change. 722 722 #expect(state.tabManager.selectedTabId == selectedBefore) ··· 759 759 TerminalLayoutSnapshot( 760 760 tabs: [ 761 761 TerminalLayoutSnapshot.TabSnapshot( 762 + id: nil, 762 763 title: "Terminal 1", 763 764 icon: nil, 764 765 tintColor: nil, 765 766 layout: .leaf( 766 767 TerminalLayoutSnapshot.SurfaceSnapshot( 768 + id: nil, 767 769 workingDirectory: "/tmp/repo/wt-1" 768 770 ) 769 771 ),